Skip to main content

morph_ir/
builder.rs

1use std::collections::HashMap;
2use std::path::Path;
3
4use oxc_allocator::Allocator;
5use oxc_ast::ast::*;
6use oxc_parser::Parser;
7use oxc_span::{GetSpan, SourceType};
8
9use crate::css_registry;
10use crate::node::{IRWindow, IRNode, IREvent};
11use crate::style::IRStyle;
12use crate::tailwind::TailwindResolver;
13use crate::transforms;
14
15pub struct IRBuilder {
16    tailwind: TailwindResolver,
17    counter: std::cell::Cell<usize>,
18    type_mode: morpher::TypeMode,
19}
20
21impl IRBuilder {
22    pub fn new() -> Self {
23        Self {
24            tailwind: TailwindResolver::new(),
25            counter: std::cell::Cell::new(0),
26            type_mode: morpher::TypeMode::default(),
27        }
28    }
29
30    /// Type mode for translating embedded logic (handlers, effects, globals).
31    /// Defaults to inference; `morph build --types` overrides it.
32    pub fn with_type_mode(mut self, mode: morpher::TypeMode) -> Self {
33        self.type_mode = mode;
34        self
35    }
36
37    /// Assign the next flat `node_NNNN` id (Python-style global counter).
38    fn next_id(&self) -> String {
39        let n = self.counter.get();
40        self.counter.set(n + 1);
41        format!("node_{n:04}")
42    }
43
44    pub fn build(
45        &self,
46        source: &morph_parser::MxSource,
47        css_rules: &[(String, morph_parser::CssRule)],
48        css_keyframes: &HashMap<String, Vec<morph_parser::CssKeyframe>>,
49    ) -> Vec<IRWindow> {
50        let mut windows = Vec::new();
51        // Ambient state map shared by every embedded-logic translation:
52        // getters read signals, setters write them, reactive const lambdas
53        // re-evaluate. Extended with component consts below, mirroring the
54        // order Python builds its per-component translator state.
55        let mut ambient_vars: HashMap<String, String> = HashMap::new();
56        let mut ambient_types: HashMap<String, String> = HashMap::new();
57        let mut all_state: Vec<HashMap<String, String>> = Vec::new();
58        for sv in source
59            .state_vars
60            .iter()
61            .chain(source.components.iter().flat_map(|c| c.state_vars.iter()))
62        {
63            let mut m = HashMap::new();
64            m.insert("getter".into(), sv.getter.clone());
65            m.insert("setter".into(), sv.setter.clone());
66            m.insert("init".into(), sv.init.clone());
67            all_state.push(m);
68            if !sv.getter.is_empty() {
69                ambient_vars.insert(sv.getter.clone(), format!("__st_{}.get()", sv.getter));
70                if let Some(ty) = infer_state_type(&sv.init) {
71                    ambient_types.insert(sv.getter.clone(), ty);
72                }
73            }
74            if !sv.setter.is_empty() {
75                ambient_vars.insert(sv.setter.clone(), format!("__st_{}.set", sv.getter));
76            }
77        }
78        let wc = source.window_config.as_ref();
79        let mut extra_headers: Vec<String> = source.extra_headers.clone();
80        // ── Module-level logic first (Python: function_declarations, global_vars) ──
81        let mut premain: Vec<String> = Vec::new();
82        for fd in &source.function_declarations {
83            self.push_snippet(
84                &mut premain,
85                &mut extra_headers,
86                &fd.source,
87                &ambient_vars,
88                &ambient_types,
89            );
90        }
91        for gv in &source.global_vars {
92            self.push_snippet(&mut premain, &mut extra_headers, gv, &ambient_vars, &ambient_types);
93        }
94        // ── Component consts become reactive lambdas; later translations ──
95        // see them as `name()` calls (Python: `auto x = []() { return …; };`).
96        let mut reactive_consts: Vec<String> = Vec::new();
97        for c in &source.components {
98            for cst in &c.consts {
99                if cst.name.is_empty() || cst.rhs.is_empty() {
100                    continue;
101                }
102                if let Some(out) =
103                    self.translate_logic(&cst.rhs, &ambient_vars, &ambient_types)
104                {
105                    let expr = out.body.trim().trim_end_matches(';').trim();
106                    if expr.is_empty() {
107                        continue;
108                    }
109                    extra_headers.extend(include_lines(&out.includes));
110                    premain.push(format!(
111                        "auto {} = []() {{ return ({}); }};",
112                        cst.name, expr
113                    ));
114                    ambient_vars.insert(cst.name.clone(), format!("{}()", cst.name));
115                    reactive_consts.push(cst.name.clone());
116                }
117            }
118        }
119        // ── Inner functions (module + component) with the extended map ──
120        for f in source
121            .inner_functions
122            .iter()
123            .chain(source.components.iter().flat_map(|c| c.inner_functions.iter()))
124        {
125            self.push_snippet(
126                &mut premain,
127                &mut extra_headers,
128                &f.source,
129                &ambient_vars,
130                &ambient_types,
131            );
132        }
133        // ── Effects: transpile callbacks now, emit `create_effect` later ──
134        let mut all_effects: Vec<HashMap<String, String>> = Vec::new();
135        for e in source
136            .effects
137            .iter()
138            .chain(source.components.iter().flat_map(|c| c.effects.iter()))
139        {
140            if let Some(out) = self.translate_logic(&e.callback, &ambient_vars, &ambient_types)
141            {
142                let lambda = out.body.trim().trim_end_matches(';').trim().to_string();
143                if lambda.is_empty() {
144                    continue;
145                }
146                extra_headers.extend(include_lines(&out.includes));
147                let mut m = HashMap::new();
148                m.insert("lambda".into(), lambda);
149                m.insert("deps".into(), e.deps.clone());
150                all_effects.push(m);
151            }
152        }
153        extra_headers.sort();
154        extra_headers.dedup();
155        // Startup logs: module logs, then each component's body logs
156        // (Python: per-component `body_logs` → window `startup_logs`).
157        let mut startup_logs = source.console_logs.clone();
158        for c in &source.components {
159            for log in &c.console_logs {
160                if !startup_logs.contains(log) {
161                    startup_logs.push(log.clone());
162                }
163            }
164        }
165        let mut nodes = Vec::new();
166        for comp in &source.components {
167            nodes.push(self.build_node(
168                &comp.jsx,
169                css_rules,
170                0,
171                &[],
172                &ambient_vars,
173                &ambient_types,
174                &mut extra_headers,
175                css_keyframes,
176            ));
177        }
178        extra_headers.sort();
179        extra_headers.dedup();
180        let mut window = IRWindow {
181            window_id: self.next_id(),
182            title: wc.map(|w| w.title.clone()).unwrap_or_else(|| "Morph App".into()),
183            width: wc.map(|w| w.width).unwrap_or(800),
184            height: wc.map(|w| w.height).unwrap_or(600),
185            visible: true,
186            min_width: wc.and_then(|w| w.min_width),
187            max_width: wc.and_then(|w| w.max_width),
188            min_height: wc.and_then(|w| w.min_height),
189            max_height: wc.and_then(|w| w.max_height),
190            modal: wc.map(|w| w.modal).unwrap_or(false),
191            renderer: "flash".into(),
192            nodes: vec![],
193            startup_logs,
194            premain_functions: premain,
195            extra_headers,
196            state_vars: all_state,
197            reactive_consts,
198            effect_decls: all_effects,
199            cpp_imports: source.cpp_imports.iter().map(|ci| {
200                let base = Path::new(&source.filename).parent().unwrap_or_else(|| Path::new("."));
201                let path = base.join(&ci.path);
202                let abs_path = path.canonicalize().unwrap_or_else(|_| path);
203                let mut m = HashMap::new();
204                m.insert("path".into(), abs_path.display().to_string());
205                m.insert("specifiers".into(), ci.specifiers.join(", "));
206                m
207            }).collect(),
208            keyframes: self.convert_keyframes(css_keyframes),
209        };
210        window.nodes = nodes;
211        windows.push(window);
212        windows
213    }
214
215    /// Translate embedded JS/TS against ambient app state. `None` when the
216    /// snippet does not parse or has no translatable content (the caller
217    /// skips it, mirroring Python's per-block try/except).
218    fn translate_logic(
219        &self,
220        source: &str,
221        ambient_vars: &HashMap<String, String>,
222        ambient_types: &HashMap<String, String>,
223    ) -> Option<morpher::SnippetOutput> {
224        let mut options = morpher::TranslateOptions::default();
225        options.type_mode = self.type_mode;
226        options.state_vars = ambient_vars.clone();
227        options.state_types = ambient_types.clone();
228        morpher::translate_snippet(source, "snippet.ts", options).ok().filter(|out| {
229            !out.body.trim().is_empty()
230        })
231    }
232
233    /// Translate a statement-level snippet and splice its body into `premain`
234    /// with external linkage (mirrors Python's `strip_static_function`).
235    fn push_snippet(
236        &self,
237        premain: &mut Vec<String>,
238        extra_headers: &mut Vec<String>,
239        source: &str,
240        ambient_vars: &HashMap<String, String>,
241        ambient_types: &HashMap<String, String>,
242    ) {
243        if let Some(out) = self.translate_logic(source, ambient_vars, ambient_types) {
244            extra_headers.extend(include_lines(&out.includes));
245            let body = strip_static_linkage(&out.body);
246            if !body.is_empty() {
247                premain.push(body);
248            }
249        }
250    }
251
252    fn build_node(
253        &self,
254        jsx: &morph_parser::JsxNode,
255        css_rules: &[(String, morph_parser::CssRule)],
256        depth: usize,
257        ancestors: &[AncestorHint],
258        ambient_vars: &HashMap<String, String>,
259        ambient_types: &HashMap<String, String>,
260        extra_headers: &mut Vec<String>,
261        keyframes: &HashMap<String, Vec<morph_parser::CssKeyframe>>,
262    ) -> IRNode {
263        match jsx {
264            morph_parser::JsxNode::Element { tag, props, children, line: _, col: _, .. } => {
265                let node_id = self.next_id();
266                let mut node = IRNode {
267                    node_id: node_id.clone(),
268                    node_type: tag.clone(),
269                    ..Default::default()
270                };
271                let mut style = IRStyle::new();
272                apply_ua_defaults(&mut style, tag);
273                let mut hover_style = IRStyle::new();
274                for (prop, val) in ua_hover_defaults(tag) { apply_css_prop(&mut hover_style, prop, val); }
275                let mut active_style = IRStyle::new();
276                for (prop, val) in ua_active_defaults(tag) { apply_css_prop(&mut active_style, prop, val); }
277                let (classes, id) = element_classes_id(props);
278                // Cascade: collect every matching declaration with its
279                // specificity and source order, then apply weakest-first so
280                // the winner writes last. Stable sort keeps source order
281                // among equal specificities (later sheets win ties).
282                let mut declarations: Vec<(Specificity, usize, String, String, PseudoKind)> =
283                    Vec::new();
284                for (order, (selector, rule)) in css_rules.iter().enumerate() {
285                    if let Some((pseudo, specificity)) =
286                        match_selector_detailed(tag, &classes, id.as_deref(), ancestors, selector)
287                    {
288                        for (prop, val) in &rule.properties {
289                            declarations.push((
290                                specificity,
291                                order,
292                                prop.clone(),
293                                val.clone(),
294                                pseudo,
295                            ));
296                        }
297                    }
298                }
299                declarations.sort();
300                // Winning declarations per bucket, for animation parsing
301                // (Python: matched CSS merged before tailwind/attrs/inline).
302                let mut base_props: HashMap<String, String> = HashMap::new();
303                let mut hover_props: HashMap<String, String> = HashMap::new();
304                for (_, _, prop, val, pseudo) in &declarations {
305                    let target = match pseudo {
306                        PseudoKind::Base => &mut style,
307                        PseudoKind::Hover => &mut hover_style,
308                        PseudoKind::Active => &mut active_style,
309                    };
310                    apply_css_prop(target, prop, val);
311                    match pseudo {
312                        PseudoKind::Base => {
313                            base_props.insert(prop.clone(), val.clone());
314                        }
315                        PseudoKind::Hover => {
316                            hover_props.insert(prop.clone(), val.clone());
317                        }
318                        PseudoKind::Active => {}
319                    }
320                }
321                if let Some(morph_parser::JsxPropValue::String(cls)) = props.get("className").or_else(|| props.get("class")) {
322                    for (prop, val) in self.tailwind.resolve_many(cls) {
323                        apply_css_prop(&mut style, &prop, &val);
324                        base_props.insert(prop, val);
325                    }
326                }
327                // Presentational hints lose to every stylesheet rule: HTML
328                // width/height attributes apply only when the cascade left
329                // the property unset (browsers treat them as weakest).
330                if style.width.is_none() {
331                    if let Some(morph_parser::JsxPropValue::String(raw)) = props.get("width") {
332                        if let Some(px) = parse_length(raw) {
333                            style.width = Some(px);
334                            base_props.insert("width".to_string(), raw.clone());
335                        }
336                    }
337                }
338                if style.height.is_none() {
339                    if let Some(morph_parser::JsxPropValue::String(raw)) = props.get("height") {
340                        if let Some(px) = parse_length(raw) {
341                            style.height = Some(px);
342                            base_props.insert("height".to_string(), raw.clone());
343                        }
344                    }
345                }
346                if let Some(morph_parser::JsxPropValue::Style(map)) = props.get("style") {
347                    for (prop, val) in map {
348                        match val {
349                            morph_parser::StyleValue::Static(s) => {
350                                apply_css_prop(&mut style, prop, s);
351                                base_props.insert(prop.clone(), s.clone());
352                            }
353                            morph_parser::StyleValue::Expr(e) => { node.reactive_style.insert(prop.clone(), e.clone()); }
354                        }
355                    }
356                }
357                node.style = style;
358                if !hover_style.is_empty_style() { node.hover_style = Some(hover_style); }
359                if !active_style.is_empty_style() { node.active_style = Some(active_style); }
360                // CSS animations from merged declarations; keyframe names
361                // unknown to the build are dropped like browsers do.
362                node.animations = parse_animations(&base_props)
363                    .into_iter()
364                    .filter(|anim| keyframes.contains_key(&anim.name))
365                    .collect();
366                node.hover_animations = parse_animations(&hover_props)
367                    .into_iter()
368                    .filter(|anim| keyframes.contains_key(&anim.name))
369                    .collect();
370                for (k, v) in props {
371                    if let morph_parser::JsxPropValue::Fn(f) = v {
372                        if let Some(trigger) = event_trigger(k) {
373                            node.events.push(IREvent {
374                                trigger: trigger.into(),
375                                action: "call".into(),
376                                target: f.clone(),
377                            });
378                            continue;
379                        }
380                    }
381                    match (k.as_str(), v) {
382                        ("id", morph_parser::JsxPropValue::String(s)) => { node.attrs.insert("id".into(), s.clone()); }
383                        ("src", morph_parser::JsxPropValue::String(s)) => { node.attrs.insert("src".into(), s.clone()); }
384                        ("placeholder", morph_parser::JsxPropValue::String(s)) => { node.attrs.insert("placeholder".into(), s.clone()); }
385                        ("type", morph_parser::JsxPropValue::String(s)) => { node.attrs.insert("type".into(), s.clone()); }
386                        // Static class strings only drive build-time matching;
387                        // only dynamic className={...} becomes a reactive
388                        // expression (translated with state at emit time).
389                        // Stuffing static strings through JS translation
390                        // mangles any word colliding with state (`key op`
391                        // with an `op` signal became `key __st_op.get()`).
392                        ("className", morph_parser::JsxPropValue::Expr(s))
393                        | ("class", morph_parser::JsxPropValue::Expr(s))
394                        | ("className", morph_parser::JsxPropValue::Template(s))
395                        | ("class", morph_parser::JsxPropValue::Template(s))
396                        | ("className", morph_parser::JsxPropValue::Ref(s))
397                        | ("class", morph_parser::JsxPropValue::Ref(s)) => {
398                            // Runtime class string via the full translator.
399                            if let Some(out) =
400                                self.translate_logic(s, ambient_vars, ambient_types)
401                            {
402                                extra_headers.extend(include_lines(&out.includes));
403                                let body = out
404                                    .body
405                                    .trim()
406                                    .trim_end_matches(';')
407                                    .trim()
408                                    .to_string();
409                                if !body.is_empty() {
410                                    node.reactive_class = body;
411                                }
412                            }
413                            // Build-time branch resolution for ternary arms.
414                            let mut fx = analyze_dynamic_class(
415                                s,
416                                tag,
417                                css_rules,
418                                &self.tailwind,
419                                ambient_vars,
420                                ambient_types,
421                                self.type_mode,
422                                extra_headers,
423                            );
424                            node.class_conditional_effects.append(&mut fx);
425                        }
426                        _ => {}
427                    }
428                }
429                let text_parts: Vec<String> = children.iter().filter_map(|c| if let morph_parser::JsxNode::Text(t) = c { Some(t.clone()) } else { None }).collect();
430                if !text_parts.is_empty() { node.text_content = text_parts.join(""); }
431                let mut child_ancestors = ancestors.to_vec();
432                child_ancestors.insert(0, AncestorHint {
433                    tag: tag.clone(),
434                    classes: classes.clone(),
435                    id: id.clone(),
436                });
437                for child in children.iter() {
438                    let child_node = self.build_node(
439                        child,
440                        css_rules,
441                        depth + 1,
442                        &child_ancestors,
443                        ambient_vars,
444                        ambient_types,
445                        extra_headers,
446                        keyframes,
447                    );
448                    if child_node.node_type == "__text__" && child_node.text_content.trim().is_empty() {
449                        continue;
450                    }
451                    node.children.push(child_node);
452                }
453                node
454            }
455            morph_parser::JsxNode::Fragment { children, .. } => {
456                let mut node = IRNode { node_id: self.next_id(), node_type: "__fragment__".into(), ..Default::default() };
457                for child in children.iter() {
458                    node.children.push(self.build_node(
459                        child,
460                        css_rules,
461                        depth,
462                        ancestors,
463                        ambient_vars,
464                        ambient_types,
465                        extra_headers,
466                        keyframes,
467                    ));
468                }
469                node
470            }
471            morph_parser::JsxNode::Text(t) => {
472                let mut node = IRNode { node_id: self.next_id(), node_type: "__text__".into(), ..Default::default() };
473                node.text_content = t.clone();
474                node
475            }
476            morph_parser::JsxNode::Expression(e) => {
477                let mut node = IRNode { node_id: self.next_id(), node_type: "__expr__".into(), ..Default::default() };
478                node.reactive_text = e.clone();
479                node
480            }
481            morph_parser::JsxNode::Conditional { condition, then_branch, else_branch, .. } => {
482                let mut node = IRNode { node_id: self.next_id(), node_type: "__conditional__".into(), ..Default::default() };
483                node.condition_expr = condition.clone();
484                for c in then_branch.iter() {
485                    node.then_nodes.push(self.build_node(
486                        c,
487                        css_rules,
488                        depth,
489                        ancestors,
490                        ambient_vars,
491                        ambient_types,
492                        extra_headers,
493                        keyframes,
494                    ));
495                }
496                for c in else_branch.iter() {
497                    node.else_nodes.push(self.build_node(
498                        c,
499                        css_rules,
500                        depth,
501                        ancestors,
502                        ambient_vars,
503                        ambient_types,
504                        extra_headers,
505                        keyframes,
506                    ));
507                }
508                node
509            }
510            morph_parser::JsxNode::List { array_expr, key_expr, item_template, .. } => {
511                let mut node = IRNode { node_id: self.next_id(), node_type: "__list__".into(), ..Default::default() };
512                node.list_expr = array_expr.clone();
513                node.list_key_expr = key_expr.clone();
514                node.item_template = Some(Box::new(self.build_node(
515                    item_template,
516                    css_rules,
517                    depth,
518                    ancestors,
519                    ambient_vars,
520                    ambient_types,
521                    extra_headers,
522                    keyframes,
523                )));
524                node
525            }
526        }
527    }
528
529    fn convert_keyframes(
530        &self,
531        css_keyframes: &HashMap<String, Vec<morph_parser::CssKeyframe>>,
532    ) -> HashMap<String, Vec<crate::node::IRKeyframe>> {
533        let mut result: HashMap<String, Vec<crate::node::IRKeyframe>> = HashMap::new();
534        for (name, kfs) in css_keyframes {
535            let mut converted = Vec::new();
536            for kf in kfs {
537                let mut raw: HashMap<String, String> = HashMap::new();
538                let mut style = IRStyle::new();
539                let mut declared: Vec<String> = Vec::new();
540                for (prop, val) in &kf.properties {
541                    if !is_animatable(prop) { continue; }
542                    if prop == "transform" || needs_layout(val) {
543                        raw.insert(prop.clone(), val.clone());
544                        continue;
545                    }
546                    if let Some(field) = apply_css_prop(&mut style, prop, val) {
547                        declared.push(field.to_string());
548                    }
549                }
550                converted.push(crate::node::IRKeyframe {
551                    offset: kf.offset,
552                    style,
553                    declared,
554                    raw,
555                });
556            }
557            result.insert(name.clone(), converted);
558        }
559        result
560    }
561}
562
563/// Which style bucket a matched CSS rule targets.
564#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
565enum PseudoKind {
566    Base,
567    Hover,
568    Active,
569}
570
571fn is_animatable(prop: &str) -> bool {
572    matches!(
573        prop,
574        "opacity" | "background-color" | "color" | "border-radius" | "font-size"
575            | "width" | "height" | "left" | "top" | "transform"
576    )
577}
578
579fn needs_layout(val: &str) -> bool {
580    let v = val.trim();
581    if v.is_empty() || v == "auto" { return true; }
582    v.ends_with('%') || v.ends_with("vh") || v.ends_with("vw")
583}
584
585/// User-agent default styles for HTML tags (lowest priority — overridden by
586/// every later cascade stage). Mirrors Python's `_UA_DEFAULTS` verbatim; the
587/// `color: #010101` near-black sentinel on form controls signals "explicit
588/// value, never inherit the parent's color".
589fn ua_defaults(tag: &str) -> &'static [(&'static str, &'static str)] {
590    match tag {
591        // ── Document ────────────────────────────────────────────
592        "html" => &[("display", "block")],
593        "body" => &[("display", "block"), ("padding", "8px")],
594
595        // ── Headings ────────────────────────────────────────────
596        "h1" => &[("display", "block"), ("font-size", "32px"), ("font-weight", "bold"), ("margin", "21.44px 0")],
597        "h2" => &[("display", "block"), ("font-size", "24px"), ("font-weight", "bold"), ("margin", "19.92px 0")],
598        "h3" => &[("display", "block"), ("font-size", "18.72px"), ("font-weight", "bold"), ("margin", "18.72px 0")],
599        "h4" => &[("display", "block"), ("font-size", "16px"), ("font-weight", "bold"), ("margin", "21.28px 0")],
600        "h5" => &[("display", "block"), ("font-size", "13.28px"), ("font-weight", "bold"), ("margin", "22.18px 0")],
601        "h6" => &[("display", "block"), ("font-size", "10.72px"), ("font-weight", "bold"), ("margin", "24.97px 0")],
602
603        // ── Grouping ────────────────────────────────────────────
604        "div" => &[("display", "block")],
605        "p" => &[("display", "block"), ("margin", "16px 0")],
606        "pre" => &[("display", "block"), ("margin", "16px 0")],
607        "blockquote" => &[("display", "block"), ("margin", "16px 40px")],
608        "hr" => &[("display", "block")],
609        "figure" => &[("display", "block"), ("margin", "16px 40px")],
610        "figcaption" => &[("display", "block")],
611        "main" => &[("display", "block")],
612        "header" => &[("display", "block")],
613        "footer" => &[("display", "block")],
614        "nav" => &[("display", "block")],
615        "section" => &[("display", "block")],
616        "article" => &[("display", "block")],
617        "aside" => &[("display", "block")],
618
619        // ── Lists ───────────────────────────────────────────────
620        "ul" => &[("display", "block"), ("margin", "16px 0")],
621        "ol" => &[("display", "block"), ("margin", "16px 0")],
622        "li" => &[("display", "block")],
623        "dl" => &[("display", "block"), ("margin", "16px 0")],
624        "dt" => &[("display", "block")],
625        "dd" => &[("display", "block"), ("margin-left", "40px")],
626
627        // ── Text-level ──────────────────────────────────────────
628        "span" => &[("display", "inline")],
629        "a" => &[("display", "inline"), ("color", "#0000ee"), ("cursor", "pointer")],
630        "strong" => &[("font-weight", "bold")],
631        "b" => &[("font-weight", "bold")],
632        "small" => &[("font-size", "13.28px")],
633        "mark" => &[("background-color", "#ffff00"), ("color", "#000000")],
634        "sub" => &[("font-size", "13.28px")],
635        "sup" => &[("font-size", "13.28px")],
636        "code" => &[("display", "inline")],
637        "kbd" => &[("display", "inline")],
638        "samp" => &[("display", "inline")],
639        "em" => &[("display", "inline")],
640        "i" => &[("display", "inline")],
641        "ins" => &[("display", "inline")],
642        "u" => &[("display", "inline")],
643        "del" => &[("display", "inline")],
644        "s" => &[("display", "inline")],
645        "q" => &[("display", "inline")],
646
647        // ── Embedded ────────────────────────────────────────────
648        "img" => &[("display", "inline-block")],
649
650        // ── Forms ───────────────────────────────────────────────
651        "button" => &[
652            ("display", "inline-block"),
653            ("background-color", "#efefef"),
654            ("color", "#010101"),
655            ("border-width", "1px"),
656            ("border-style", "solid"),
657            ("border-color", "#767676"),
658            ("border-radius", "4px"),
659            ("padding", "1px 6px"),
660            ("font-size", "13.33px"),
661            ("text-align", "center"),
662        ],
663        "input" => &[
664            ("display", "inline-block"),
665            ("background-color", "#ffffff"),
666            ("color", "#010101"),
667            ("border-width", "1px"),
668            ("border-style", "solid"),
669            ("border-color", "#767676"),
670            ("border-radius", "4px"),
671            ("padding", "1px 6px"),
672            ("font-size", "13.33px"),
673            ("cursor", "text"),
674        ],
675        "select" => &[("display", "inline-block")],
676        "textarea" => &[("display", "inline-block")],
677        "label" => &[("display", "inline")],
678        "fieldset" => &[("display", "block"), ("border-width", "2px"), ("border-style", "groove"), ("margin", "0 2px"), ("padding", "5px 12px 10px")],
679        "legend" => &[("display", "block"), ("padding", "0 2px")],
680        "form" => &[("display", "block")],
681
682        // ── Tables ──────────────────────────────────────────────
683        "table" => &[("display", "block")],
684        "caption" => &[("display", "block")],
685        "thead" => &[("display", "block")],
686        "tbody" => &[("display", "block")],
687        "tfoot" => &[("display", "block")],
688        "tr" => &[("display", "block")],
689        "td" => &[("display", "block")],
690        "th" => &[("display", "block"), ("font-weight", "bold"), ("text-align", "center")],
691
692        // ── Interactive ─────────────────────────────────────────
693        "details" => &[("display", "block")],
694        "summary" => &[("display", "block")],
695        "dialog" => &[("display", "block")],
696
697        _ => &[],
698    }
699}
700
701/// User-agent default :hover styles (lowest priority — merged FIRST, any
702/// matching user `:hover` rule overrides per-property, like browsers).
703fn ua_hover_defaults(tag: &str) -> &'static [(&'static str, &'static str)] {
704    match tag {
705        "button" => &[("background-color", "#e6e6e6")],
706        _ => &[],
707    }
708}
709
710/// User-agent default :active styles (pressed state — darker face + border,
711/// mirroring the browser's buttonface → buttonhighlight/buttonshadow shift).
712fn ua_active_defaults(tag: &str) -> &'static [(&'static str, &'static str)] {
713    match tag {
714        "button" => &[("background-color", "#d4d4d4"), ("border-color", "#5a5a5a")],
715        _ => &[],
716    }
717}
718
719fn apply_ua_defaults(style: &mut IRStyle, tag: &str) {
720    for (prop, val) in ua_defaults(tag) {
721        apply_css_prop(style, prop, val);
722    }
723}
724
725/// Infer a C++ type for a state init literal so snippet translations get
726/// operand classes and member-access style for ambient reads. Mirrors the
727/// init-based inference in `morph-codegen`'s emitter.
728fn infer_state_type(init: &str) -> Option<String> {
729    let s = init.trim();
730    if s == "true" || s == "false" {
731        return Some("bool".to_string());
732    }
733    if (s.starts_with('"') && s.ends_with('"') && s.len() >= 2)
734        || (s.starts_with('\'') && s.ends_with('\'') && s.len() >= 2)
735    {
736        return Some("std::string".to_string());
737    }
738    if s.starts_with('[') && s.ends_with(']') {
739        return Some("JsArray".to_string());
740    }
741    if s.parse::<i64>().is_ok() {
742        return Some("int".to_string());
743    }
744    if s.parse::<f64>().is_ok() {
745        return Some("double".to_string());
746    }
747    None
748}
749
750/// Remove `static`/`static inline` linkage from a translated top-level
751/// function so it gets external linkage in the app TU (mirrors Python's
752/// `strip_static_function`, visible to user .cpp code).
753fn strip_static_linkage(cpp: &str) -> String {
754    let mut text = cpp.trim().to_string();
755    for prefix in ["static inline", "static"] {
756        if text.starts_with(prefix)
757            && text[prefix.len()..].chars().next().map(|c| c.is_whitespace()).unwrap_or(false)
758        {
759            text = text[prefix.len()..].trim_start().to_string();
760            break;
761        }
762    }
763    text
764}
765
766/// Analyze a dynamic `className` template/expression for ternary branches
767/// with string-literal arms (`... ${cond ? "a" : "b"}`). Each branch's
768/// classes resolve to CSS declarations at build time (Tailwind + matching
769/// stylesheet rules, pseudo rules skipped), and the condition becomes a
770/// translated C++ bool expression. Mirrors Python's
771/// `_analyze_class_template` / `_analyze_class_expression`.
772/// Returns the conditional effects; best-effort (unparseable input yields none).
773fn analyze_dynamic_class(
774    source: &str,
775    tag: &str,
776    css_rules: &[(String, morph_parser::CssRule)],
777    tailwind: &TailwindResolver,
778    ambient_vars: &HashMap<String, String>,
779    ambient_types: &HashMap<String, String>,
780    type_mode: morpher::TypeMode,
781    extra_headers: &mut Vec<String>,
782) -> Vec<crate::node::IRConditionalClassEffect> {
783    let mut effects = Vec::new();
784    let allocator = Allocator::default();
785    let source_type = SourceType::from_path("snippet.ts")
786        .unwrap_or_default()
787        .with_typescript(true);
788    let parsed = Parser::new(&allocator, source, source_type).parse();
789    if parsed.panicked || !parsed.diagnostics.is_empty() {
790        return effects;
791    }
792    let first = parsed.program.body.first();
793    let expr = match first {
794        Some(Statement::ExpressionStatement(stmt)) => &stmt.expression,
795        _ => return effects,
796    };
797    // Collect top-level ternary expressions: the template's ${...} parts
798    // plus a bare ternary expression.
799    let mut ternaries: Vec<&ConditionalExpression> = Vec::new();
800    match expr {
801        Expression::TemplateLiteral(tpl) => {
802            for part in tpl.expressions.iter() {
803                if let Expression::ConditionalExpression(cond) = part {
804                    ternaries.push(cond);
805                }
806            }
807        }
808        Expression::ConditionalExpression(cond) => ternaries.push(cond),
809        _ => {}
810    }
811    for ternary in ternaries {
812        let on_str = string_branch(&ternary.consequent);
813        let off_str = string_branch(&ternary.alternate);
814        let on_styles = resolve_branch_classes(&on_str, tag, css_rules, tailwind);
815        let off_styles = resolve_branch_classes(&off_str, tag, css_rules, tailwind);
816        if on_styles.is_empty() && off_styles.is_empty() {
817            continue;
818        }
819        let cond_src = &source[ternary.test.span().start as usize..ternary.test.span().end as usize];
820        let mut options = morpher::TranslateOptions::default();
821        options.type_mode = type_mode;
822        options.state_vars = ambient_vars.clone();
823        options.state_types = ambient_types.clone();
824        let cond_cpp = morpher::translate_snippet(cond_src, "snippet.ts", options)
825            .ok()
826            .map(|out| {
827                extra_headers.extend(include_lines(&out.includes));
828                out.body.trim().trim_end_matches(';').trim().to_string()
829            })
830            .unwrap_or_default();
831        if cond_cpp.is_empty() {
832            continue;
833        }
834        effects.push(crate::node::IRConditionalClassEffect {
835            condition: cond_cpp,
836            on_styles,
837            off_styles,
838        });
839    }
840    effects
841}
842
843/// The class string of a ternary branch when it is a plain string literal.
844fn string_branch(expr: &Expression) -> String {
845    match expr {
846        Expression::StringLiteral(lit) => lit.value.to_string(),
847        _ => String::new(),
848    }
849}
850
851/// Resolve a branch's class tokens to CSS declarations: Tailwind first,
852/// then matching non-pseudo stylesheet rules (later winners overwrite).
853fn resolve_branch_classes(
854    class_str: &str,
855    tag: &str,
856    css_rules: &[(String, morph_parser::CssRule)],
857    tailwind: &TailwindResolver,
858) -> HashMap<String, String> {
859    let mut out = HashMap::new();
860    let tokens: Vec<String> = class_str
861        .split_whitespace()
862        .map(|t| t.trim().to_string())
863        .filter(|t| !t.is_empty())
864        .collect();
865    for token in &tokens {
866        for (prop, val) in tailwind.resolve(token) {
867            out.insert(prop, val);
868        }
869    }
870    for (selector, rule) in css_rules {
871        if selector.contains(":hover") || selector.contains(":active") {
872            continue;
873        }
874        if match_selector_detailed(tag, &tokens, None, &[], selector).is_some() {
875            for (prop, val) in &rule.properties {
876                out.insert(prop.clone(), val.clone());
877            }
878        }
879    }
880    out
881}
882
883/// Collect bare `#include` specs (`<string>`, `"x.h"`) from a snippet's
884/// split-off header block. The app template adds the `#include` keyword
885/// itself, mirroring Python's translator `_needed` set.
886fn include_lines(header: &str) -> Vec<String> {
887    header
888        .lines()
889        .map(str::trim)
890        .filter_map(|l| l.strip_prefix("#include"))
891        .map(|l| l.trim().to_string())
892        .filter(|l| !l.is_empty())
893        .collect()
894}
895/// Split a leading/trailing `:hover` / `:active` pseudo-class off a simple or
896/// compound selector. Only the *last* component's pseudo applies to the element
897/// itself; earlier-ancestor pseudos are not handled by this builder.
898fn split_trailing_pseudo(sel: &str) -> Option<(&str, Option<PseudoKind>)> {
899    let mut pseudo: Option<PseudoKind> = None;
900    let mut s = sel;
901    loop {
902        let t = s.trim_end();
903        if let Some(rest) = t.strip_suffix(":hover") {
904            pseudo = Some(PseudoKind::Hover);
905            s = rest;
906        } else if let Some(rest) = t.strip_suffix(":active") {
907            pseudo = Some(PseudoKind::Active);
908            s = rest;
909        } else {
910            break;
911        }
912    }
913    Some((s.trim_end(), pseudo))
914}
915
916/// Match a single (possibly compound, pseudo-stripped) selector against the
917/// element. No combinators/descendant selectors are supported here.
918fn match_selector_compound(tag: &str, classes: &[String], id: Option<&str>, sel: &str) -> bool {
919    let sel = sel.trim();
920    if sel.is_empty() { return false; }
921    if sel == "*" { return true; }
922
923    let mut matched_tag = false;
924    let mut tag_found = false;
925    let mut required_classes: Vec<&str> = Vec::new();
926    let mut has_id = false;
927    let mut id_ok = true;
928
929    let bytes = sel.as_bytes();
930    let mut i = 0;
931    let mut buf = String::new();
932
933    while i < bytes.len() {
934        let ch = sel[i..].chars().next().unwrap();
935        match ch {
936            '.' => {
937                flush_tag(&mut buf, &mut tag_found, &mut matched_tag, tag);
938                i += 1;
939                let start = i;
940                while i < bytes.len() && !" .#:[]>~+*".contains(sel[i..].chars().next().unwrap()) {
941                    i += sel[i..].chars().next().unwrap().len_utf8();
942                }
943                if i > start { required_classes.push(&sel[start..i]); }
944            }
945            '#' => {
946                flush_tag(&mut buf, &mut tag_found, &mut matched_tag, tag);
947                i += 1;
948                let start = i;
949                while i < bytes.len() && !" .#:[]>~+*".contains(sel[i..].chars().next().unwrap()) {
950                    i += sel[i..].chars().next().unwrap().len_utf8();
951                }
952                has_id = true;
953                if id.map(|v| v == &sel[start..i]).unwrap_or(false) {
954                    // id matches
955                } else {
956                    id_ok = false;
957                }
958            }
959            ':' | '[' | '>' | '~' | '+' | ' ' => {
960                // Unsupported pseudo/attribute/descendant — remaining structural
961                // tail is not a valid element matcher for this builder.
962                break;
963            }
964            _ => {
965                buf.push(ch);
966                i += 1;
967            }
968        }
969    }
970    // Trailing tag text after the last class/id token.
971    flush_tag(&mut buf, &mut tag_found, &mut matched_tag, tag);
972
973    if has_id && !id_ok { return false; }
974    if tag_found && !matched_tag { return false; }
975    for c in required_classes {
976        if !classes.iter().any(|cl| cl.as_str() == c) {
977            return false;
978        }
979    }
980    true
981}
982
983/// Map a JSX event prop to its trigger name. Anything unlisted is not
984/// an event the runtime wires, so it falls through to attribute handling.
985fn event_trigger(prop: &str) -> Option<&'static str> {
986    match prop {
987        "onClick" => Some("click"),
988        "onInput" => Some("input"),
989        "onChange" => Some("change"),
990        "onFocus" => Some("focus"),
991        "onBlur" => Some("blur"),
992        "onKeyUp" => Some("keyup"),
993        "onKeyDown" => Some("keydown"),
994        "onMouseEnter" => Some("mouseenter"),
995        "onMouseLeave" => Some("mouseleave"),
996        "onMouseDown" => Some("mousedown"),
997        "onMouseUp" => Some("mouseup"),
998        _ => None,
999    }
1000}
1001
1002/// Class list and id of an element, shared by matching and ancestry.
1003fn element_classes_id(
1004    props: &std::collections::HashMap<String, morph_parser::JsxPropValue>,
1005) -> (Vec<String>, Option<String>) {
1006    let classes = match props.get("className").or_else(|| props.get("class")) {
1007        Some(morph_parser::JsxPropValue::String(c)) => {
1008            c.split_whitespace().map(|s| s.to_string()).collect()
1009        }
1010        _ => Vec::new(),
1011    };
1012    let id = props.get("id").and_then(|v| match v {
1013        morph_parser::JsxPropValue::String(s) => Some(s.clone()),
1014        _ => None,
1015    });
1016    (classes, id)
1017}
1018
1019/// Flush a buffered bare tag token (e.g. `button` in `button.btn.ghost`).
1020fn flush_tag(buf: &mut String, tag_found: &mut bool, matched_tag: &mut bool, tag: &str) {
1021    let s = buf.trim();
1022    if !s.is_empty() && s != "*" {
1023        *tag_found = true;
1024        if s == tag { *matched_tag = true; }
1025    }
1026    buf.clear();
1027}
1028
1029/// One ancestor step for descendant-selector matching.
1030#[derive(Clone, Default)]
1031struct AncestorHint {
1032    tag: String,
1033    classes: Vec<String>,
1034    id: Option<String>,
1035}
1036
1037/// Selector specificity as (ids, classes, tags): higher wins regardless
1038/// of source order, matching browser cascade. Pseudo-classes count as
1039/// classes. `!important` is not tracked (the parser merges it away), and
1040/// sibling combinators are unsupported (see below).
1041#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default, Debug)]
1042struct Specificity(u32, u32, u32);
1043
1044/// Count specificity of one comma-free alternative: `#` per id, `.` and
1045/// `:` runs per class/pseudo-class, bare leading words per tag.
1046fn selector_specificity(alternative: &str) -> Specificity {
1047    let mut ids = 0;
1048    let mut classes = 0;
1049    let mut tags = 0;
1050    let mut word_start = true;
1051    let mut chars = alternative.chars().peekable();
1052    while let Some(ch) = chars.next() {
1053        match ch {
1054            '#' => {
1055                ids += 1;
1056                word_start = false;
1057            }
1058            '.' => {
1059                classes += 1;
1060                word_start = false;
1061            }
1062            ':' => {
1063                if chars.peek() == Some(&':') {
1064                    chars.next();
1065                }
1066                classes += 1;
1067                word_start = false;
1068            }
1069            '[' => {
1070                classes += 1;
1071                word_start = false;
1072            }
1073            ' ' | '>' | '+' | '~' | ',' | '*' => {
1074                word_start = true;
1075            }
1076            _ => {
1077                if word_start && ch.is_alphabetic() {
1078                    tags += 1;
1079                }
1080                word_start = false;
1081            }
1082        }
1083    }
1084    Specificity(ids, classes, tags)
1085}
1086
1087/// How one compound attaches to the previous one. Sibling combinators
1088/// (`+`, `~`) have no meaning in a flat single-node walk.
1089#[derive(Clone, Copy, PartialEq, Eq)]
1090enum Combinator {
1091    Descendant,
1092    Child,
1093}
1094
1095/// Split `div > p` into per-compound steps with their combinators.
1096/// `None` for sibling combinators and attribute selectors: ignoring the
1097/// rule beats applying it to the wrong element.
1098fn split_selector_sequence(selector: &str) -> Option<Vec<(Option<Combinator>, String)>> {
1099    if selector.contains('[') {
1100        return None;
1101    }
1102    let mut steps: Vec<(Option<Combinator>, String)> = Vec::new();
1103    let mut current = String::new();
1104    let mut pending = None;
1105    let mut chars = selector.chars().peekable();
1106    while let Some(ch) = chars.next() {
1107        match ch {
1108            ' ' | '\t' => {
1109                if !current.trim().is_empty() {
1110                    steps.push((pending.take(), current.trim().to_string()));
1111                    current = String::new();
1112                }
1113                if pending.is_none() {
1114                    pending = Some(Combinator::Descendant);
1115                }
1116            }
1117            '>' => {
1118                if !current.trim().is_empty() {
1119                    steps.push((pending.take(), current.trim().to_string()));
1120                    current = String::new();
1121                }
1122                pending = Some(Combinator::Child);
1123            }
1124            '+' | '~' => {
1125                return None;
1126            }
1127            _ => {
1128                current.push(ch);
1129            }
1130        }
1131    }
1132    if !current.trim().is_empty() {
1133        steps.push((pending.take(), current.trim().to_string()));
1134    }
1135    if steps.is_empty() {
1136        return None;
1137    }
1138    Some(steps)
1139}
1140
1141/// Match full steps right-to-left: the last compound hits the element,
1142/// the rest walk the ancestor chain (`ancestors[0]` is the parent).
1143fn match_sequence(
1144    tag: &str,
1145    classes: &[String],
1146    id: Option<&str>,
1147    ancestors: &[AncestorHint],
1148    steps: &[(Option<Combinator>, String)],
1149) -> bool {
1150    let Some((_, last)) = steps.last() else {
1151        return false;
1152    };
1153    if !match_selector_compound(tag, classes, id, last) {
1154        return false;
1155    }
1156    let mut ancestor_at = 0;
1157    for index in (1..steps.len()).rev() {
1158        let compound = &steps[index - 1].1;
1159        match steps[index].0 {
1160            None | Some(Combinator::Child) => {
1161                let Some(ancestor) = ancestors.get(ancestor_at) else {
1162                    return false;
1163                };
1164                if !match_selector_compound(
1165                    &ancestor.tag,
1166                    &ancestor.classes,
1167                    ancestor.id.as_deref(),
1168                    compound,
1169                ) {
1170                    return false;
1171                }
1172                ancestor_at += 1;
1173            }
1174            Some(Combinator::Descendant) => {
1175                let mut found = false;
1176                while let Some(ancestor) = ancestors.get(ancestor_at) {
1177                    ancestor_at += 1;
1178                    if match_selector_compound(
1179                        &ancestor.tag,
1180                        &ancestor.classes,
1181                        ancestor.id.as_deref(),
1182                        compound,
1183                    ) {
1184                        found = true;
1185                        break;
1186                    }
1187                }
1188                if !found {
1189                    return false;
1190                }
1191            }
1192        }
1193    }
1194    true
1195}
1196
1197/// Match with specificity of the winning alternative. Unknown pseudos
1198/// and attribute selectors never match (unsupported, ignored); among
1199/// matching alternatives the most specific one counts, not the first.
1200fn match_selector_detailed(
1201    tag: &str,
1202    classes: &[String],
1203    id: Option<&str>,
1204    ancestors: &[AncestorHint],
1205    selector: &str,
1206) -> Option<(PseudoKind, Specificity)> {
1207    let mut best: Option<(PseudoKind, Specificity)> = None;
1208    for alternative in selector.trim().split(',') {
1209        let alternative = alternative.trim();
1210        if alternative.is_empty() {
1211            continue;
1212        }
1213        let (structural, pseudo) = match split_trailing_pseudo(alternative) {
1214            Some(split) => split,
1215            None => (alternative, None),
1216        };
1217        if structural.contains(':') || structural.contains('[') {
1218            continue;
1219        }
1220        let Some(steps) = split_selector_sequence(structural) else {
1221            continue;
1222        };
1223        if match_sequence(tag, classes, id, ancestors, &steps) {
1224            let specificity = selector_specificity(alternative);
1225            let better = best.map(|(_, held)| specificity > held).unwrap_or(true);
1226            if better {
1227                best = Some((pseudo.unwrap_or(PseudoKind::Base), specificity));
1228            }
1229        }
1230    }
1231    best
1232}
1233
1234/// Apply a CSS property to a style, returning the IR field name that was set
1235/// (used for `@keyframes` declared-field tracking), or None if unsupported.
1236fn apply_css_prop(style: &mut IRStyle, prop: &str, val: &str) -> Option<&'static str> {
1237    if !css_registry::is_known_property(prop) { return None; }
1238    match prop {
1239        "background-color" | "background" => if let Some(c) = parse_color(val) { style.bg_color = c; Some("bg_color") } else { None },
1240        "color" => if let Some(c) = parse_color(val) { style.color = c; Some("color") } else { None },
1241        "width" => if let Some(v) = parse_length(val) { style.width = Some(v); Some("width") } else { None },
1242        "height" => if let Some(v) = parse_length(val) { style.height = Some(v); Some("height") } else { None },
1243        "min-width" => if let Some(v) = parse_length(val) { style.min_width = Some(v); Some("min_width") } else { None },
1244        "max-width" => if let Some(v) = parse_length(val) { style.max_width = Some(v); Some("max_width") } else { None },
1245        "min-height" => if let Some(v) = parse_length(val) { style.min_height = Some(v); Some("min_height") } else { None },
1246        "max-height" => if let Some(v) = parse_length(val) { style.max_height = Some(v); Some("max_height") } else { None },
1247        "padding" => if let Some(v) = parse_box_sides(val) { style.padding = v; Some("padding") } else { None },
1248        "margin" => if let Some(v) = parse_box_sides(val) { style.margin = v; Some("margin") } else { None },
1249        "border-radius" => if let Some(v) = parse_length(val) { style.border_radius = v; Some("border_radius") } else { None },
1250        "font-size" => if let Some(v) = parse_length(val) { style.font_size = v; Some("font_size") } else { None },
1251        "font-weight" => { style.font_weight = val.to_string(); Some("font_weight") }
1252        "text-align" => { style.text_align = val.to_string(); Some("text_align") }
1253        "display" => { style.display = val.to_string(); Some("display") }
1254        "flex-direction" => { style.flex_dir = val.to_string(); Some("flex_dir") }
1255        "gap" => if let Some(v) = parse_length(val) { style.gap = v; Some("gap") } else { None },
1256        "position" => { style.position = val.to_string(); Some("position") }
1257        "left" => {
1258            style.left = parse_length(val);
1259            if style.left.is_some() { Some("left") } else { None }
1260        }
1261        "right" => {
1262            style.right = parse_length(val);
1263            if style.right.is_some() { Some("right") } else { None }
1264        }
1265        "top" => {
1266            style.top = parse_length(val);
1267            if style.top.is_some() { Some("top") } else { None }
1268        }
1269        "bottom" => {
1270            style.bottom = parse_length(val);
1271            if style.bottom.is_some() { Some("bottom") } else { None }
1272        }
1273        "justify-content" => { style.justify_content = val.to_string(); Some("justify_content") }
1274        "align-items" => { style.align_items = val.to_string(); Some("align_items") }
1275        "flex-wrap" => { style.flex_wrap = val.to_string(); Some("flex_wrap") }
1276        "flex-grow" => if let Ok(v) = val.trim().parse::<f32>() {
1277            style.flex_grow = v; Some("flex_grow")
1278        } else { None },
1279        "flex-shrink" => if let Ok(v) = val.trim().parse::<f32>() {
1280            style.flex_shrink = v; Some("flex_shrink")
1281        } else { None },
1282        "flex-basis" => {
1283            let v = val.trim();
1284            style.flex_basis = if v == "auto" {
1285                "auto".to_string()
1286            } else if let Some(px) = parse_length(v) {
1287                format!("{}px", px)
1288            } else {
1289                v.to_string()
1290            };
1291            Some("flex_basis")
1292        }
1293        "flex" => { parse_flex_shorthand(&mut *style, val); Some("flex") }
1294        "border" => { parse_border_shorthand(&mut *style, val); Some("border") }
1295        "margin-top" => if let Some(v) = parse_length(val) {
1296            style.margin[0] = v; Some("margin")
1297        } else { None },
1298        "margin-right" => if let Some(v) = parse_length(val) {
1299            style.margin[1] = v; Some("margin")
1300        } else { None },
1301        "margin-bottom" => if let Some(v) = parse_length(val) {
1302            style.margin[2] = v; Some("margin")
1303        } else { None },
1304        "margin-left" => if let Some(v) = parse_length(val) {
1305            style.margin[3] = v; Some("margin")
1306        } else { None },
1307        "padding-top" => if let Some(v) = parse_length(val) {
1308            style.padding[0] = v; Some("padding")
1309        } else { None },
1310        "padding-right" => if let Some(v) = parse_length(val) {
1311            style.padding[1] = v; Some("padding")
1312        } else { None },
1313        "padding-bottom" => if let Some(v) = parse_length(val) {
1314            style.padding[2] = v; Some("padding")
1315        } else { None },
1316        "padding-left" => if let Some(v) = parse_length(val) {
1317            style.padding[3] = v; Some("padding")
1318        } else { None },
1319        "cursor" => { style.cursor = val.to_string(); Some("cursor") }
1320        "overflow" => { style.overflow = val.to_string(); Some("overflow") }
1321        "opacity" => if let Ok(v) = val.trim().parse::<f32>() { style.opacity = v; Some("opacity") } else { None },
1322        "transform" => {
1323            // Resolve at build time so the emitted style carries a concrete
1324            // matrix (Python resolves via its layout engine in dev; prod leaves
1325            // it unresolved, so Rust is strictly a superset here).
1326            match transforms::parse_transform(val) {
1327                Some(ops) => {
1328                    style.transform_ops = Some(ops.clone());
1329                    if !ops.is_empty() {
1330                        style.transform_matrix =
1331                            Some(transforms::compose_transform(&ops, 0.0, 0.0));
1332                    }
1333                    Some("transform")
1334                }
1335                None => None,
1336            }
1337        }
1338        "transform-origin" => {
1339            match transforms::parse_transform_origin(val) {
1340                Some((raw, resolved)) => {
1341                    style.transform_origin = Some(raw);
1342                    style.transform_origin_resolved = resolved;
1343                    Some("transform_origin")
1344                }
1345                None => None,
1346            }
1347        }
1348        "z-index" => if let Ok(v) = val.parse::<i32>() { style.z_index = Some(v); Some("z_index") } else { None },
1349        "border-width" => if let Some(v) = parse_length(val) { style.border_width = v; Some("border_width") } else { None },
1350        "border-color" => if let Some(c) = parse_color(val) { style.border_color = c; Some("border_color") } else { None },
1351        "border-style" => { style.border_style = val.to_string(); Some("border_style") }
1352        "box-sizing" => { style.box_sizing = val.to_string(); Some("box_sizing") }
1353        _ => None,
1354    }
1355}
1356
1357/// Parse the CSS `flex` shorthand into grow/shrink/basis.
1358/// Mirrors Python `_parse_flex_shorthand`.
1359fn parse_flex_shorthand(style: &mut IRStyle, val: &str) {
1360    let kw = val.trim();
1361    let parts: Vec<&str> = kw.split_whitespace().collect();
1362    match kw {
1363        "none" => {
1364            style.flex_grow = 0.0;
1365            style.flex_shrink = 0.0;
1366            style.flex_basis = "auto".to_string();
1367        }
1368        "auto" => {
1369            style.flex_grow = 1.0;
1370            style.flex_shrink = 1.0;
1371            style.flex_basis = "auto".to_string();
1372        }
1373        "initial" => {
1374            style.flex_grow = 0.0;
1375            style.flex_shrink = 1.0;
1376            style.flex_basis = "auto".to_string();
1377        }
1378        _ => match parts.len() {
1379            1 => {
1380                if let Ok(v) = parts[0].parse::<f32>() {
1381                    style.flex_grow = v;
1382                    style.flex_shrink = 1.0;
1383                    style.flex_basis = "0%".to_string();
1384                }
1385            }
1386            2 => {
1387                if let (Ok(g), Ok(s)) =
1388                    (parts[0].parse::<f32>(), parts[1].parse::<f32>())
1389                {
1390                    style.flex_grow = g;
1391                    style.flex_shrink = s;
1392                    style.flex_basis = "0%".to_string();
1393                }
1394            }
1395            _ => {
1396                if parts.len() >= 3 {
1397                    if let (Ok(g), Ok(s)) =
1398                        (parts[0].parse::<f32>(), parts[1].parse::<f32>())
1399                    {
1400                        style.flex_grow = g;
1401                        style.flex_shrink = s;
1402                        style.flex_basis = parts[2].to_string();
1403                    }
1404                }
1405            }
1406        },
1407    }
1408}
1409
1410/// Split the CSS `border` shorthand (`1px solid #232b3d`) into
1411/// width/style/color. Mirrors Python's `_css_to_ir_kw` branch.
1412fn parse_border_shorthand(style: &mut IRStyle, val: &str) {
1413    for part in val.split_whitespace() {
1414        if matches!(part, "solid" | "dashed" | "dotted" | "none") {
1415            style.border_style = part.to_string();
1416        } else if part.starts_with('#')
1417            || part.starts_with("rgb")
1418            || part == "transparent"
1419        {
1420            if let Some(c) = parse_color(part) {
1421                style.border_color = c;
1422            }
1423        } else if let Some(w) = parse_length(part) {
1424            style.border_width = w;
1425        }
1426    }
1427}
1428
1429/// Parse CSS 1-4 value box shorthand (`10px`, `6px 12px`, ...) into
1430/// [top, right, bottom, left], mirroring Python's per-side conversion.
1431fn parse_box_sides(s: &str) -> Option<[f32; 4]> {
1432    let parts: Vec<Option<f32>> = s.split_whitespace().map(parse_length).collect();
1433    if parts.is_empty() || parts.len() > 4 || parts.iter().any(|p| p.is_none()) {
1434        return None;
1435    }
1436    let v: Vec<f32> = parts.into_iter().map(|p| p.unwrap_or(0.0)).collect();
1437    Some(match v.len() {
1438        1 => [v[0], v[0], v[0], v[0]],
1439        2 => [v[0], v[1], v[0], v[1]],
1440        3 => [v[0], v[1], v[2], v[1]],
1441        _ => [v[0], v[1], v[2], v[3]],
1442    })
1443}
1444
1445/// Parse a CSS time (`0.3s` / `500ms` / unitless seconds) to seconds.
1446fn parse_css_time(raw: &str) -> Option<f32> {
1447    let s = raw.trim().to_lowercase();
1448    if let Some(ms) = s.strip_suffix("ms") {
1449        return ms.trim().parse::<f32>().ok().map(|v| v / 1000.0);
1450    }
1451    if let Some(sec) = s.strip_suffix('s') {
1452        return sec.trim().parse::<f32>().ok();
1453    }
1454    s.parse::<f32>().ok()
1455}
1456
1457fn map_easing(low: &str) -> Option<&'static str> {
1458    // The runtime has no separate `ease` curve: it is ease-in-out,
1459    // mirroring Python's _EASING_KEYWORDS.
1460    Some(match low {
1461        "linear" => "linear",
1462        "ease" | "ease-in-out" => "ease-in-out",
1463        "ease-in" => "ease-in",
1464        "ease-out" => "ease-out",
1465        _ => return None,
1466    })
1467}
1468
1469/// Split `animation: a, b` on top-level commas (ignores parens).
1470fn split_animation_list(raw: &str) -> Vec<String> {
1471    let mut parts = Vec::new();
1472    let mut depth = 0i32;
1473    let mut cur = String::new();
1474    for ch in raw.chars() {
1475        match ch {
1476            '(' => {
1477                depth += 1;
1478                cur.push(ch);
1479            }
1480            ')' => {
1481                depth -= 1;
1482                cur.push(ch);
1483            }
1484            ',' if depth == 0 => {
1485                if !cur.trim().is_empty() {
1486                    parts.push(cur.trim().to_string());
1487                }
1488                cur = String::new();
1489            }
1490            _ => cur.push(ch),
1491        }
1492    }
1493    if !cur.trim().is_empty() {
1494        parts.push(cur.trim().to_string());
1495    }
1496    parts
1497}
1498
1499/// Parse one comma-separated `animation` shorthand value. Mirrors Python's
1500/// `_parse_animation_component` (first time = duration, second = delay,
1501/// bare numbers = iteration count, first unclassified token = name).
1502fn parse_animation_component(raw: &str) -> crate::node::IRAnimation {
1503    let mut anim = crate::node::IRAnimation::default();
1504    let mut unclassified: Vec<String> = Vec::new();
1505    for tok in raw.split_whitespace() {
1506        let low = tok.to_lowercase();
1507        if let Some(easing) = map_easing(&low) {
1508            anim.easing = easing.to_string();
1509        } else if matches!(low.as_str(), "normal" | "reverse" | "alternate" | "alternate-reverse") {
1510            anim.direction = low;
1511        } else if matches!(low.as_str(), "none" | "forwards" | "backwards" | "both") {
1512            anim.fill_mode = low;
1513        } else if matches!(low.as_str(), "running" | "paused") {
1514            anim.play_state = low;
1515        } else if low == "infinite" {
1516            anim.iterations = -1.0;
1517        } else if low.parse::<f32>().is_ok() {
1518            // Fractional counts (2.5) must not misparse as times.
1519            anim.iterations = low.parse::<f32>().unwrap_or(1.0);
1520        } else if let Some(t) = parse_css_time(&low) {
1521            if anim.duration == 0.0 {
1522                anim.duration = t;
1523            } else {
1524                anim.delay = t;
1525            }
1526        } else if !low.contains('(') {
1527            // Unsupported easing functions (cubic-bezier, steps) ignored.
1528            unclassified.push(tok.to_string());
1529        }
1530    }
1531    if !unclassified.is_empty() {
1532        anim.name = unclassified.into_iter().next().unwrap_or_default();
1533    }
1534    anim
1535}
1536
1537fn parse_animation_shorthand(raw: &str) -> Vec<crate::node::IRAnimation> {
1538    split_animation_list(raw)
1539        .iter()
1540        .map(|part| parse_animation_component(part))
1541        .filter(|anim| !anim.name.is_empty())
1542        .collect()
1543}
1544
1545const ANIMATION_LONGHANDS: &[&str] = &[
1546    "animation-name",
1547    "animation-duration",
1548    "animation-timing-function",
1549    "animation-delay",
1550    "animation-iteration-count",
1551    "animation-direction",
1552    "animation-fill-mode",
1553    "animation-play-state",
1554];
1555
1556fn apply_animation_longhand(
1557    anim: &mut crate::node::IRAnimation,
1558    prop: &str,
1559    value: &str,
1560) -> bool {
1561    let val = value.trim().to_lowercase();
1562    match prop {
1563        "animation-name" => anim.name = val,
1564        "animation-duration" => {
1565            anim.duration = parse_css_time(&val).unwrap_or(anim.duration);
1566            if parse_css_time(&val).is_none() {
1567                return false;
1568            }
1569        }
1570        "animation-timing-function" => {
1571            if let Some(easing) = map_easing(&val) {
1572                anim.easing = easing.to_string();
1573            }
1574        }
1575        "animation-delay" => {
1576            if parse_css_time(&val).is_none() {
1577                return false;
1578            }
1579            anim.delay = parse_css_time(&val).unwrap_or(anim.delay);
1580        }
1581        "animation-iteration-count" => {
1582            if val == "infinite" {
1583                anim.iterations = -1.0;
1584            } else if let Ok(n) = val.parse::<f32>() {
1585                anim.iterations = n;
1586            } else {
1587                return false;
1588            }
1589        }
1590        "animation-direction" => {
1591            if matches!(val.as_str(), "normal" | "reverse" | "alternate" | "alternate-reverse") {
1592                anim.direction = val;
1593            }
1594        }
1595        "animation-fill-mode" => {
1596            if matches!(val.as_str(), "none" | "forwards" | "backwards" | "both") {
1597                anim.fill_mode = val;
1598            }
1599        }
1600        "animation-play-state" => {
1601            if matches!(val.as_str(), "running" | "paused") {
1602                anim.play_state = val;
1603            }
1604        }
1605        _ => {}
1606    }
1607    true
1608}
1609
1610/// Build a node's animation list from merged CSS declarations: the
1611/// `animation` shorthand first, then longhands as per-index overrides
1612/// (CSS list semantics: the last value repeats). Animations without a
1613/// name are dropped; play-state alone never creates one.
1614fn parse_animations(merged: &HashMap<String, String>) -> Vec<crate::node::IRAnimation> {
1615    let mut anims: Vec<crate::node::IRAnimation> = merged
1616        .get("animation")
1617        .map(|raw| parse_animation_shorthand(raw))
1618        .unwrap_or_default();
1619    let mut longhands: Vec<(&str, Vec<String>)> = Vec::new();
1620    for prop in ANIMATION_LONGHANDS {
1621        if let Some(raw) = merged.get(*prop) {
1622            longhands.push((prop, split_animation_list(raw)));
1623        }
1624    }
1625    if longhands.is_empty() {
1626        return anims.into_iter().filter(|a| !a.name.is_empty()).collect();
1627    }
1628    let count = longhands
1629        .iter()
1630        .map(|(_, values)| values.len())
1631        .max()
1632        .unwrap_or(0)
1633        .max(anims.len());
1634    while anims.len() < count {
1635        anims.push(crate::node::IRAnimation::default());
1636    }
1637    for (prop, values) in &longhands {
1638        for (i, anim) in anims.iter_mut().enumerate().take(count) {
1639            let val = values.get(i).or_else(|| values.last());
1640            if let Some(val) = val {
1641                apply_animation_longhand(anim, prop, val);
1642            }
1643        }
1644    }
1645    anims.into_iter().filter(|a| !a.name.is_empty()).collect()
1646}
1647
1648fn parse_length(s: &str) -> Option<f32> {
1649    let s = s.trim();
1650    if let Some(num) = s.strip_suffix("px") { return num.trim().parse().ok(); }
1651    if let Some(num) = s.strip_suffix("rem") { return num.trim().parse::<f32>().ok().map(|v| v*16.0); }
1652    if let Some(num) = s.strip_suffix("em") { return num.trim().parse::<f32>().ok().map(|v| v*16.0); }
1653    if s.ends_with('%') { return None; }
1654    s.parse().ok()
1655}
1656
1657fn parse_color(s: &str) -> Option<[f32;4]> {
1658    let s = s.trim().to_lowercase();
1659    if s.starts_with('#') {
1660        let hex = s.trim_start_matches('#');
1661        let (r,g,b,a) = match hex.len() {
1662            3 => {
1663                let r = u8::from_str_radix(&hex[0..1].repeat(2), 16).ok()?;
1664                let g = u8::from_str_radix(&hex[1..2].repeat(2), 16).ok()?;
1665                let b = u8::from_str_radix(&hex[2..3].repeat(2), 16).ok()?;
1666                (r,g,b,255)
1667            }
1668            4 => {
1669                let r = u8::from_str_radix(&hex[0..1].repeat(2), 16).ok()?;
1670                let g = u8::from_str_radix(&hex[1..2].repeat(2), 16).ok()?;
1671                let b = u8::from_str_radix(&hex[2..3].repeat(2), 16).ok()?;
1672                let a = u8::from_str_radix(&hex[3..4].repeat(2), 16).ok()?;
1673                (r,g,b,a)
1674            }
1675            6 => {
1676                let r = u8::from_str_radix(&hex[0..2], 16).ok()?;
1677                let g = u8::from_str_radix(&hex[2..4], 16).ok()?;
1678                let b = u8::from_str_radix(&hex[4..6], 16).ok()?;
1679                (r,g,b,255)
1680            }
1681            8 => {
1682                let r = u8::from_str_radix(&hex[0..2], 16).ok()?;
1683                let g = u8::from_str_radix(&hex[2..4], 16).ok()?;
1684                let b = u8::from_str_radix(&hex[4..6], 16).ok()?;
1685                let a = u8::from_str_radix(&hex[6..8], 16).ok()?;
1686                (r,g,b,a)
1687            }
1688            _ => return None,
1689        };
1690        return Some([r as f32/255.0, g as f32/255.0, b as f32/255.0, a as f32/255.0]);
1691    }
1692    if s.starts_with("rgb") {
1693        return parse_rgb(&s);
1694    }
1695    match s.as_str() {
1696        "transparent" => Some([0.0,0.0,0.0,0.0]),
1697        "white" => Some([1.0,1.0,1.0,1.0]),
1698        "black" => Some([0.0,0.0,0.0,1.0]),
1699        "red" => Some([1.0,0.0,0.0,1.0]),
1700        "green" => Some([0.0,0.5,0.0,1.0]),
1701        "blue" => Some([0.0,0.0,1.0,1.0]),
1702        "gray" | "grey" => Some([0.5,0.5,0.5,1.0]),
1703        _ => None,
1704    }
1705}
1706
1707/// Parse `rgb(r,g,b)` / `rgba(r,g,b,a)` — components may be ints (0-255) or
1708/// percentages, and alpha may be a 0..1 float or percentage.
1709fn parse_rgb(s: &str) -> Option<[f32;4]> {
1710    let inner = s.find('(')?;
1711    let end = s.rfind(')')?;
1712    let args = &s[inner+1..end];
1713    let parts: Vec<&str> = args.split(',').map(|p| p.trim()).filter(|p| !p.is_empty()).collect();
1714    if parts.len() < 3 { return None; }
1715
1716    let comp = |p: &str| -> Option<f32> {
1717        let p = p.trim();
1718        if let Some(v) = p.strip_suffix('%') {
1719            Some(v.trim().parse::<f32>().ok()? / 100.0)
1720        } else {
1721            Some(p.parse::<f32>().ok()? / 255.0)
1722        }
1723    };
1724
1725    let r = comp(parts[0])?;
1726    let g = comp(parts[1])?;
1727    let b = comp(parts[2])?;
1728    let a = if parts.len() >= 4 {
1729        let p = parts[3].trim();
1730        if let Some(v) = p.strip_suffix('%') {
1731            v.trim().parse::<f32>().ok()? / 100.0
1732        } else {
1733            p.parse::<f32>().ok()?
1734        }
1735    } else {
1736        1.0
1737    };
1738    Some([r, g, b, a])
1739}
1740
1741impl Default for IRBuilder {
1742    fn default() -> Self { Self::new() }
1743}
1744
1745#[cfg(test)]
1746mod tests {
1747    use super::*;
1748    use morph_parser::JsxPropValue;
1749
1750    fn match_sel(sel: &str, cls: &[&str]) -> bool {
1751        let mut props = std::collections::HashMap::new();
1752        props.insert("className".to_string(), JsxPropValue::String(cls.join(" ")));
1753        let (classes, id) = element_classes_id(&props);
1754        match_selector_detailed("button", &classes, id.as_deref(), &[], sel).is_some()
1755    }
1756
1757    #[test]
1758    fn compound_ghost() {
1759        assert!(match_sel(".btn.ghost", &["btn", "ghost"]), ".btn.ghost should match btn ghost");
1760        assert!(!match_sel(".btn.ghost", &["btn"]), ".btn.ghost should NOT match btn only");
1761        assert!(match_sel(".btn", &["btn", "ghost"]), ".btn should match btn ghost");
1762        assert!(match_sel(".btn.ghost:hover", &["btn", "ghost"]), "hover compound should match");
1763    }
1764
1765    #[test]
1766    fn transparent_parsed() {
1767        // lightningcss serializes `background-color: transparent` as #0000 (4-digit)
1768        // and `rgba(79,123,255,0)` as #4f7cff00 (8-digit). Both must parse to alpha 0.
1769        let close = |a: Option<[f32; 4]>, b: [f32; 4]| -> bool {
1770            match a {
1771                Some(v) => (0..4).all(|i| (v[i] - b[i]).abs() < 0.001),
1772                None => false,
1773            }
1774        };
1775        assert!(close(parse_color("transparent"), [0.0, 0.0, 0.0, 0.0]));
1776        assert!(close(parse_color("#0000"), [0.0, 0.0, 0.0, 0.0]));
1777        assert!(close(parse_color("#4f7cff00"), [0.3098, 0.4863, 1.0, 0.0]));
1778        assert!(close(parse_color("rgba(79, 124, 255, 0)"), [0.3098, 0.4863, 1.0, 0.0]));
1779        assert!(close(parse_color("rgba(255,255,255,0.5)"), [1.0, 1.0, 1.0, 0.5]));
1780        assert!(close(parse_color("rgb(255,0,0)"), [1.0, 0.0, 0.0, 1.0]));
1781    }
1782
1783    #[test]
1784    fn specificity_orders_id_over_class_over_tag() {
1785        assert!(selector_specificity("#a") > selector_specificity(".b.c.d"));
1786        assert!(selector_specificity(".b") > selector_specificity("div"));
1787        assert!(selector_specificity("div p") > selector_specificity("p"));
1788        assert_eq!(selector_specificity(".a"), selector_specificity(".b"));
1789    }
1790
1791    fn detailed(
1792        tag: &str,
1793        classes: &[&str],
1794        ancestors: &[(&str, &[&str])],
1795        selector: &str,
1796    ) -> Option<(PseudoKind, Specificity)> {
1797        let owned_classes: Vec<String> = classes.iter().map(|s| s.to_string()).collect();
1798        let owned_ancestors: Vec<AncestorHint> = ancestors
1799            .iter()
1800            .map(|(tag, classes)| AncestorHint {
1801                tag: tag.to_string(),
1802                classes: classes.iter().map(|s| s.to_string()).collect(),
1803                id: None,
1804            })
1805            .collect();
1806        match_selector_detailed(tag, &owned_classes, None, &owned_ancestors, selector)
1807    }
1808
1809    #[test]
1810    fn descendant_matches_through_ancestors() {
1811        let wrap: &[&str] = &["wrap"];
1812        let ancestors = [("div", wrap)];
1813        assert!(detailed("p", &[], &ancestors, "div p").is_some());
1814        assert!(detailed("p", &[], &[], "div p").is_none());
1815        assert!(detailed("span", &[], &ancestors, "div > span").is_some());
1816    }
1817
1818    #[test]
1819    fn sibling_combinators_never_match() {
1820        let empty: &[&str] = &[];
1821        let ancestors = [("h2", empty)];
1822        assert!(detailed("p", &[], &ancestors, "h2 + p").is_none());
1823        assert!(detailed("p", &[], &ancestors, "h2 ~ p").is_none());
1824    }
1825
1826    #[test]
1827    fn grouped_alternatives_use_matching_specificity() {
1828        let matched = detailed("p", &["note"], &[], ".note, #other").unwrap();
1829        assert_eq!(matched.1, selector_specificity(".note"));
1830    }
1831
1832    #[test]
1833    fn width_attribute_loses_to_stylesheet() {
1834        let builder = IRBuilder::new();
1835        let mut props = std::collections::HashMap::new();
1836        props.insert("width".to_string(), JsxPropValue::String("400".to_string()));
1837        let node = builder.build_node(
1838            &morph_parser::JsxNode::Element {
1839                tag: "img".to_string(),
1840                props,
1841                children: Vec::new(),
1842                self_closing: true,
1843                line: 0,
1844                col: 0,
1845            },
1846            &[],
1847            0,
1848            &[],
1849            &HashMap::new(),
1850            &HashMap::new(),
1851            &mut Vec::new(),
1852            &HashMap::new(),
1853        );
1854        assert_eq!(node.style.width, Some(400.0));
1855
1856        let mut props = std::collections::HashMap::new();
1857        props.insert("width".to_string(), JsxPropValue::String("400".to_string()));
1858        let rules = vec![(
1859            ".wide".to_string(),
1860            morph_parser::CssRule {
1861                selector: ".wide".to_string(),
1862                properties: [("width".to_string(), "100px".to_string())]
1863                    .into_iter()
1864                    .collect(),
1865            },
1866        )];
1867        props.insert("className".to_string(), JsxPropValue::String("wide".to_string()));
1868        let node = builder.build_node(
1869            &morph_parser::JsxNode::Element {
1870                tag: "img".to_string(),
1871                props,
1872                children: Vec::new(),
1873                self_closing: true,
1874                line: 0,
1875                col: 0,
1876            },
1877            &rules,
1878            0,
1879            &[],
1880            &HashMap::new(),
1881            &HashMap::new(),
1882            &mut Vec::new(),
1883            &HashMap::new(),
1884        );
1885        assert_eq!(node.style.width, Some(100.0));
1886    }
1887
1888    #[test]
1889    fn embedded_logic_transpiles_to_cpp_premain() {
1890        use morph_parser::{
1891            ComponentConst, InnerFunction, MxComponent, MxEffect, MxSource, StateVar,
1892        };
1893        let source = MxSource {
1894            filename: "app.mx".to_string(),
1895            imports: Vec::new(),
1896            window_config: None,
1897            components: vec![MxComponent {
1898                name: "App".to_string(),
1899                exported: true,
1900                params: Vec::new(),
1901                jsx: morph_parser::JsxNode::Text("hi".to_string()),
1902                state_vars: vec![StateVar {
1903                    getter: "count".to_string(),
1904                    setter: "setCount".to_string(),
1905                    init: "0".to_string(),
1906                }],
1907                effects: vec![
1908                    MxEffect {
1909                        callback: "() => { console.log(count); }".to_string(),
1910                        deps: "[]".to_string(),
1911                    },
1912                    MxEffect {
1913                        callback: "() => { console.log(count); }".to_string(),
1914                        deps: "[count]".to_string(),
1915                    },
1916                ],
1917                inner_functions: vec![InnerFunction {
1918                    name: "doLogin".to_string(),
1919                    source: "function doLogin() { setCount(count + 1); }".to_string(),
1920                }],
1921                consts: vec![ComponentConst {
1922                    name: "doubled".to_string(),
1923                    rhs: "count * 2".to_string(),
1924                }],
1925                console_logs: vec!["body log".to_string()],
1926            }],
1927            state_vars: Vec::new(),
1928            effects: Vec::new(),
1929            inner_functions: Vec::new(),
1930            function_declarations: vec![InnerFunction {
1931                name: "helper".to_string(),
1932                source: "function helper() { return 1; }".to_string(),
1933            }],
1934            global_vars: vec!["const API_URL = \"https://api.test\";".to_string()],
1935            console_logs: vec!["module log".to_string()],
1936            extra_headers: Vec::new(),
1937            cpp_imports: Vec::new(),
1938        };
1939        let windows = IRBuilder::new().build(&source, &[], &HashMap::new());
1940        assert_eq!(windows.len(), 1);
1941        let win = &windows[0];
1942        let premain = win.premain_functions.join("\n");
1943        // Raw JS must never reach the app TU: functions are transpiled and
1944        // stripped of internal linkage, consts become reactive lambdas.
1945        assert!(premain.contains("void doLogin()"), "handler transpiled: {}", premain);
1946        assert!(premain.contains("auto helper"), "module fn transpiled: {}", premain);
1947        assert!(premain.contains("API_URL"), "global transpiled: {}", premain);
1948        assert!(!premain.contains("function "), "no raw JS: {}", premain);
1949        assert!(
1950            premain.contains("auto doubled = []() { return ("),
1951            "const is reactive lambda: {}",
1952            premain
1953        );
1954        assert!(
1955            premain.contains("__st_count.get()"),
1956            "ambient state mapped: {}",
1957            premain
1958        );
1959        assert!(!premain.contains("static "), "external linkage: {}", premain);
1960        assert_eq!(win.reactive_consts, vec!["doubled".to_string()]);
1961        // Effects carry transpiled lambdas, not JS callbacks.
1962        assert_eq!(win.effect_decls.len(), 2);
1963        assert!(win.effect_decls[0].get("lambda").unwrap().starts_with('['));
1964        assert!(!win.effect_decls[0].get("lambda").unwrap().contains("=>"));
1965        assert_eq!(win.effect_decls[0].get("deps").unwrap(), "[]");
1966        assert_eq!(win.effect_decls[1].get("deps").unwrap(), "[count]");
1967        // Snippet headers (e.g. <print> for console.log) merge upward.
1968        assert!(win.extra_headers.iter().any(|h| h.contains("print")), "{:?}", win.extra_headers);
1969        // Logs merge: module first, then component body.
1970        assert_eq!(win.startup_logs, vec!["module log".to_string(), "body log".to_string()]);
1971    }
1972
1973    #[test]
1974    fn box_shorthands_expand_per_side() {
1975        assert_eq!(parse_box_sides("18px"), Some([18.0, 18.0, 18.0, 18.0]));
1976        assert_eq!(parse_box_sides("10px 6px"), Some([10.0, 6.0, 10.0, 6.0]));
1977        assert_eq!(
1978            parse_box_sides("36px 32px 28px 32px"),
1979            Some([36.0, 32.0, 28.0, 32.0])
1980        );
1981        assert_eq!(parse_box_sides("1px 2px 3px"), Some([1.0, 2.0, 3.0, 2.0]));
1982        assert_eq!(parse_box_sides("10px auto"), None);
1983        assert_eq!(parse_box_sides(""), None);
1984    }
1985
1986    #[test]
1987    fn flex_and_border_shorthands_map() {
1988        let builder = IRBuilder::new();
1989        let mut style = IRStyle::default();
1990        apply_css_prop(&mut style, "flex-grow", "1");
1991        apply_css_prop(&mut style, "flex-shrink", "0");
1992        assert_eq!(style.flex_grow, 1.0);
1993        assert_eq!(style.flex_shrink, 0.0);
1994        apply_css_prop(&mut style, "flex", "2");
1995        assert_eq!(style.flex_grow, 2.0);
1996        assert_eq!(style.flex_shrink, 1.0);
1997        assert_eq!(style.flex_basis, "0%");
1998        apply_css_prop(&mut style, "border", "1px solid #232b3d");
1999        assert_eq!(style.border_width, 1.0);
2000        assert_eq!(style.border_style, "solid");
2001        assert!(style.border_color[0] > 0.1 && style.border_color[0] < 0.2);
2002        apply_css_prop(&mut style, "margin-top", "50px");
2003        apply_css_prop(&mut style, "padding-left", "6px");
2004        assert_eq!(style.margin[0], 50.0);
2005        assert_eq!(style.padding[3], 6.0);
2006        let _ = builder;
2007    }
2008
2009    #[test]
2010    fn static_class_names_stay_out_of_reactive_class() {
2011        // `key op` with an `op` signal must survive verbatim; only
2012        // className={...} becomes a reactive expression.
2013        let builder = IRBuilder::new();
2014        let mut props = std::collections::HashMap::new();
2015        props.insert(
2016            "className".to_string(),
2017            JsxPropValue::String("key op".to_string()),
2018        );
2019        let node = builder.build_node(
2020            &morph_parser::JsxNode::Element {
2021                tag: "button".to_string(),
2022                props,
2023                children: Vec::new(),
2024                self_closing: true,
2025                line: 0,
2026                col: 0,
2027            },
2028            &[],
2029            0,
2030            &[],
2031                &HashMap::new(),
2032                &HashMap::new(),
2033                &mut Vec::new(),
2034            &HashMap::new(),
2035        );
2036        assert!(node.reactive_class.is_empty());
2037        let mut props = std::collections::HashMap::new();
2038        props.insert(
2039            "className".to_string(),
2040            JsxPropValue::Expr("op === 1 ? \"a\" : \"b\"".to_string()),
2041        );
2042        let node = builder.build_node(
2043            &morph_parser::JsxNode::Element {
2044                tag: "button".to_string(),
2045                props,
2046                children: Vec::new(),
2047                self_closing: true,
2048                line: 0,
2049                col: 0,
2050            },
2051            &[],
2052            0,
2053            &[],
2054                &HashMap::new(),
2055                &HashMap::new(),
2056                &mut Vec::new(),
2057            &HashMap::new(),
2058        );
2059        assert!(!node.reactive_class.is_empty());
2060    }
2061
2062    #[test]
2063    fn template_className_analyzes_ternary_branches() {
2064        use morph_parser::{MxComponent, MxSource, StateVar};
2065        let mut props = std::collections::HashMap::new();
2066        props.insert(
2067            "className".to_string(),
2068            JsxPropValue::Template(
2069                "`header ${theme == \"light\" ? \"bg-white\" : \"bg-gray-900\"}`".to_string(),
2070            ),
2071        );
2072        let source = MxSource {
2073            filename: "app.mx".to_string(),
2074            imports: Vec::new(),
2075            window_config: None,
2076            components: vec![MxComponent {
2077                name: "App".to_string(),
2078                exported: true,
2079                params: Vec::new(),
2080                jsx: morph_parser::JsxNode::Element {
2081                    tag: "div".to_string(),
2082                    props,
2083                    children: Vec::new(),
2084                    self_closing: true,
2085                    line: 0,
2086                    col: 0,
2087                },
2088                state_vars: vec![StateVar {
2089                    getter: "theme".to_string(),
2090                    setter: "setTheme".to_string(),
2091                    init: "\"light\"".to_string(),
2092                }],
2093                effects: Vec::new(),
2094                inner_functions: Vec::new(),
2095                consts: Vec::new(),
2096                console_logs: Vec::new(),
2097            }],
2098            state_vars: Vec::new(),
2099            effects: Vec::new(),
2100            inner_functions: Vec::new(),
2101            function_declarations: Vec::new(),
2102            global_vars: Vec::new(),
2103            console_logs: Vec::new(),
2104            extra_headers: Vec::new(),
2105            cpp_imports: Vec::new(),
2106        };
2107        let windows = IRBuilder::new().build(&source, &[], &HashMap::new());
2108        let node = &windows[0].nodes[0];
2109        assert_eq!(node.class_conditional_effects.len(), 1);
2110        let fx = &node.class_conditional_effects[0];
2111        assert!(fx.condition.contains("__st_theme"), "cond mapped: {}", fx.condition);
2112        assert_eq!(
2113            fx.on_styles.get("background-color").map(String::as_str),
2114            Some("#ffffff")
2115        );
2116        assert_eq!(
2117            fx.off_styles.get("background-color").map(String::as_str),
2118            Some("#111827")
2119        );
2120        assert!(!node.reactive_class.is_empty());
2121    }
2122
2123    #[test]
2124    fn animation_shorthand_parses_and_filters_unknown_keyframes() {
2125        use morph_parser::{CssKeyframe, CssRule};
2126        let rules = vec![(
2127            ".pulse".to_string(),
2128            CssRule {
2129                selector: ".pulse".to_string(),
2130                properties: [(
2131                    "animation".to_string(),
2132                    "pulse 2s ease-in-out infinite".to_string(),
2133                )]
2134                .into_iter()
2135                .collect(),
2136            },
2137        )];
2138        let mut keyframes = HashMap::new();
2139        keyframes.insert("pulse".to_string(), Vec::<CssKeyframe>::new());
2140        let mut props = std::collections::HashMap::new();
2141        props.insert(
2142            "className".to_string(),
2143            JsxPropValue::String("pulse".to_string()),
2144        );
2145        let jsx = morph_parser::JsxNode::Element {
2146            tag: "div".to_string(),
2147            props,
2148            children: Vec::new(),
2149            self_closing: true,
2150            line: 0,
2151            col: 0,
2152        };
2153        let builder = IRBuilder::new();
2154        let node = builder.build_node(
2155            &jsx,
2156            &rules,
2157            0,
2158            &[],
2159            &HashMap::new(),
2160            &HashMap::new(),
2161            &mut Vec::new(),
2162            &keyframes,
2163        );
2164        assert_eq!(node.animations.len(), 1);
2165        let anim = &node.animations[0];
2166        assert_eq!(anim.name, "pulse");
2167        assert_eq!(anim.duration, 2.0);
2168        assert_eq!(anim.easing, "ease-in-out");
2169        assert_eq!(anim.iterations, -1.0);
2170        // Unknown keyframe names are dropped like browsers do.
2171        let rules = vec![(
2172            ".ghost".to_string(),
2173            CssRule {
2174                selector: ".ghost".to_string(),
2175                properties: [("animation".to_string(), "nope 1s linear infinite".to_string())]
2176                    .into_iter()
2177                    .collect(),
2178            },
2179        )];
2180        let mut props = std::collections::HashMap::new();
2181        props.insert(
2182            "className".to_string(),
2183            JsxPropValue::String("ghost".to_string()),
2184        );
2185        let jsx = morph_parser::JsxNode::Element {
2186            tag: "div".to_string(),
2187            props,
2188            children: Vec::new(),
2189            self_closing: true,
2190            line: 0,
2191            col: 0,
2192        };
2193        let node = builder.build_node(
2194            &jsx,
2195            &rules,
2196            0,
2197            &[],
2198            &HashMap::new(),
2199            &HashMap::new(),
2200            &mut Vec::new(),
2201            &keyframes,
2202        );
2203        assert!(node.animations.is_empty());
2204    }
2205
2206    #[test]
2207    fn tailwind_class_names_flow_into_style() {
2208        let builder = IRBuilder::new();
2209        let mut props = std::collections::HashMap::new();
2210        props.insert(
2211            "className".to_string(),
2212            JsxPropValue::String("bg-red-500 text-lg".to_string()),
2213        );
2214        let node = builder.build_node(
2215            &morph_parser::JsxNode::Element {
2216                tag: "div".to_string(),
2217                props,
2218                children: Vec::new(),
2219                self_closing: true,
2220                line: 0,
2221                col: 0,
2222            },
2223            &[],
2224            0,
2225            &[],
2226                &HashMap::new(),
2227                &HashMap::new(),
2228                &mut Vec::new(),
2229            &HashMap::new(),
2230        );
2231        assert!((node.style.bg_color[0] - 0xef as f32 / 255.0).abs() < 0.001);
2232        assert!((node.style.bg_color[1] - 0x44 as f32 / 255.0).abs() < 0.001);
2233        assert_eq!(node.style.bg_color[3], 1.0);
2234        assert_eq!(node.style.font_size, 18.0);
2235    }
2236}