Skip to main content

fd_core/
html_export.rs

1//! HTML+CSS export: SceneGraph → standalone HTML page.
2//!
3//! Maps FD nodes to HTML elements:
4//! - Rect/Frame → `<div>` with absolute positioning
5//! - Ellipse → `<div>` with `border-radius: 50%`
6//! - Text → `<p>` with font styles
7//! - Path → inline `<svg>` element
8//! - Group → wrapper `<div>` containing children
9//!
10//! Animations (hover/press triggers) become CSS transitions.
11
12use crate::id::NodeId;
13use crate::model::*;
14use petgraph::graph::NodeIndex;
15use std::collections::HashMap;
16use std::fmt::Write;
17
18/// Convert a SceneGraph (or a subset of selected nodes) to a standalone HTML page.
19///
20/// If `selected_ids` is empty, all top-level nodes are exported.
21/// Returns a complete HTML document string.
22pub fn export_html(
23    graph: &SceneGraph,
24    bounds: &HashMap<NodeIndex, ResolvedBounds>,
25    selected_ids: &[String],
26) -> String {
27    let root_selection = collect_root_selection(graph, selected_ids);
28
29    let mut elements = Vec::new();
30    let mut css_rules = Vec::new();
31    let mut id_counter: u64 = 1;
32
33    for &idx in &root_selection {
34        collect_html_elements(
35            graph,
36            idx,
37            bounds,
38            &mut elements,
39            &mut css_rules,
40            &mut id_counter,
41        );
42    }
43
44    build_html_document(&elements, &css_rules)
45}
46
47/// An intermediate HTML element representation.
48struct HtmlElement {
49    tag: &'static str,
50    css_class: String,
51    inline_style: String,
52    content: String,
53    children: Vec<HtmlElement>,
54    /// SVG content for path elements.
55    svg_content: Option<String>,
56}
57
58/// Collect root-level node indices to export, filtering by selection.
59fn collect_root_selection(graph: &SceneGraph, selected_ids: &[String]) -> Vec<NodeIndex> {
60    if selected_ids.is_empty() {
61        return graph.children(graph.root);
62    }
63
64    let mut roots = Vec::new();
65    for id_str in selected_ids {
66        let id = NodeId::intern(id_str);
67        if let Some(idx) = graph.index_of(id) {
68            let has_selected_ancestor = selected_ids
69                .iter()
70                .any(|other| other != id_str && graph.is_ancestor_of(NodeId::intern(other), id));
71            if !has_selected_ancestor {
72                roots.push(idx);
73            }
74        }
75    }
76    roots
77}
78
79/// Recursively collect HTML elements from the scene graph.
80fn collect_html_elements(
81    graph: &SceneGraph,
82    idx: NodeIndex,
83    bounds: &HashMap<NodeIndex, ResolvedBounds>,
84    elements: &mut Vec<HtmlElement>,
85    css_rules: &mut Vec<String>,
86    id_counter: &mut u64,
87) {
88    let node = &graph.graph[idx];
89    let b = match bounds.get(&idx) {
90        Some(b) => b,
91        None => return,
92    };
93
94    let style = graph.resolve_style(node, &[]);
95    let class_name = format!("fd-{}", {
96        let v = *id_counter;
97        *id_counter += 1;
98        v
99    });
100
101    let mut inline_styles = Vec::new();
102
103    // Position and size
104    inline_styles.push(format!("left: {}px", format_num(b.x)));
105    inline_styles.push(format!("top: {}px", format_num(b.y)));
106    inline_styles.push(format!("width: {}px", format_num(b.width)));
107    inline_styles.push(format!("height: {}px", format_num(b.height)));
108    inline_styles.push("position: absolute".to_string());
109
110    // Fill
111    if let Some(ref fill) = style.fill {
112        inline_styles.push(format!("background: {}", paint_to_css(fill)));
113    }
114
115    // Stroke
116    if let Some(ref stroke) = style.stroke {
117        inline_styles.push(format!(
118            "border: {}px solid {}",
119            format_num(stroke.width),
120            paint_to_css(&stroke.paint)
121        ));
122        inline_styles.push("box-sizing: border-box".to_string());
123    }
124
125    // Corner radius
126    if let Some(r) = style.corner_radius
127        && r > 0.0
128    {
129        inline_styles.push(format!("border-radius: {}px", format_num(r)));
130    }
131
132    // Opacity
133    if let Some(op) = style.opacity
134        && (op - 1.0).abs() > 0.001
135    {
136        inline_styles.push(format!("opacity: {}", format_num(op)));
137    }
138
139    // Shadow
140    if let Some(ref shadow) = style.shadow {
141        let color = color_to_css(&shadow.color);
142        inline_styles.push(format!(
143            "box-shadow: {}px {}px {}px {}",
144            format_num(shadow.offset_x),
145            format_num(shadow.offset_y),
146            format_num(shadow.blur),
147            color,
148        ));
149    }
150
151    // Build animations as CSS transitions
152    let has_animations = !node.animations.is_empty();
153    if has_animations {
154        inline_styles.push("transition: all 0.3s ease".to_string());
155        build_animation_css(&class_name, &node.animations, &style, css_rules);
156    }
157
158    let mut element = match &node.kind {
159        NodeKind::Rect { .. } | NodeKind::Frame { .. } | NodeKind::Image { .. } => {
160            if matches!(&node.kind, NodeKind::Frame { clip: true, .. }) {
161                inline_styles.push("overflow: hidden".to_string());
162            }
163            HtmlElement {
164                tag: "div",
165                css_class: class_name.clone(),
166                inline_style: inline_styles.join("; "),
167                content: String::new(),
168                children: Vec::new(),
169                svg_content: None,
170            }
171        }
172        NodeKind::Ellipse { .. } => {
173            inline_styles.push("border-radius: 50%".to_string());
174            HtmlElement {
175                tag: "div",
176                css_class: class_name.clone(),
177                inline_style: inline_styles.join("; "),
178                content: String::new(),
179                children: Vec::new(),
180                svg_content: None,
181            }
182        }
183        NodeKind::Text { content, .. } => {
184            // Text styling
185            if let Some(ref font) = style.font {
186                inline_styles.push(format!("font-family: '{}', sans-serif", font.family));
187                inline_styles.push(format!("font-size: {}px", format_num(font.size)));
188                if font.weight != 400 {
189                    inline_styles.push(format!("font-weight: {}", font.weight));
190                }
191            }
192
193            // Text alignment
194            let text_align = style.text_align.unwrap_or(TextAlign::Center);
195            inline_styles.push(format!(
196                "text-align: {}",
197                match text_align {
198                    TextAlign::Left => "left",
199                    TextAlign::Center => "center",
200                    TextAlign::Right => "right",
201                }
202            ));
203
204            // Vertical alignment via flexbox
205            let valign = style.text_valign.unwrap_or(TextVAlign::Middle);
206            inline_styles.push("display: flex".to_string());
207            inline_styles.push(format!(
208                "align-items: {}",
209                match valign {
210                    TextVAlign::Top => "flex-start",
211                    TextVAlign::Middle => "center",
212                    TextVAlign::Bottom => "flex-end",
213                }
214            ));
215            inline_styles.push(format!(
216                "justify-content: {}",
217                match text_align {
218                    TextAlign::Left => "flex-start",
219                    TextAlign::Center => "center",
220                    TextAlign::Right => "flex-end",
221                }
222            ));
223
224            // Text color from fill
225            if let Some(ref fill) = style.fill {
226                inline_styles.push(format!("color: {}", paint_to_css(fill)));
227            }
228
229            // Remove background for text (fill is used for text color)
230            inline_styles.retain(|s| !s.starts_with("background:"));
231
232            inline_styles.push("margin: 0".to_string());
233            inline_styles.push("white-space: pre-wrap".to_string());
234            inline_styles.push("word-break: break-word".to_string());
235
236            // Convert FD newlines (backslash) to HTML line breaks
237            let html_content = content.replace('\\', "<br>");
238
239            HtmlElement {
240                tag: "p",
241                css_class: class_name.clone(),
242                inline_style: inline_styles.join("; "),
243                content: html_content,
244                children: Vec::new(),
245                svg_content: None,
246            }
247        }
248        NodeKind::Path { commands } => {
249            let svg = path_to_svg(commands, b);
250            HtmlElement {
251                tag: "div",
252                css_class: class_name.clone(),
253                inline_style: inline_styles.join("; "),
254                content: String::new(),
255                children: Vec::new(),
256                svg_content: Some(svg),
257            }
258        }
259        NodeKind::Group | NodeKind::Root | NodeKind::Generic => HtmlElement {
260            tag: "div",
261            css_class: class_name.clone(),
262            inline_style: inline_styles.join("; "),
263            content: String::new(),
264            children: Vec::new(),
265            svg_content: None,
266        },
267    };
268
269    // Recurse into children
270    for child_idx in graph.children(idx) {
271        let mut child_elements = Vec::new();
272        collect_html_elements(
273            graph,
274            child_idx,
275            bounds,
276            &mut child_elements,
277            css_rules,
278            id_counter,
279        );
280        element.children.extend(child_elements);
281    }
282
283    elements.push(element);
284}
285
286/// Build CSS animation rules for hover/press triggers.
287fn build_animation_css(
288    class_name: &str,
289    animations: &[AnimKeyframe],
290    _base_style: &Properties,
291    css_rules: &mut Vec<String>,
292) {
293    for anim in animations {
294        let pseudo_class = match anim.trigger {
295            AnimTrigger::Hover => ":hover",
296            AnimTrigger::Press => ":active",
297            _ => continue, // Enter/Custom not supported as CSS yet
298        };
299
300        let duration_s = anim.duration_ms as f64 / 1000.0;
301        let easing = match &anim.easing {
302            Easing::Linear => "linear".to_string(),
303            Easing::EaseIn => "ease-in".to_string(),
304            Easing::EaseOut => "ease-out".to_string(),
305            Easing::EaseInOut => "ease-in-out".to_string(),
306            Easing::Spring => "cubic-bezier(0.175, 0.885, 0.32, 1.275)".to_string(),
307            Easing::CubicBezier(a, b, c, d) => {
308                format!("cubic-bezier({}, {}, {}, {})", a, b, c, d)
309            }
310        };
311
312        let mut props = Vec::new();
313
314        if let Some(ref fill) = anim.properties.fill {
315            props.push(format!("background: {}", paint_to_css(fill)));
316        }
317        if let Some(op) = anim.properties.opacity {
318            props.push(format!("opacity: {}", format_num(op)));
319        }
320        if let Some(scale) = anim.properties.scale {
321            let mut transforms = vec![format!("scale({})", format_num(scale))];
322            if let Some((tx, ty)) = anim.properties.translate {
323                transforms.push(format!(
324                    "translate({}px, {}px)",
325                    format_num(tx),
326                    format_num(ty)
327                ));
328            }
329            if let Some(rot) = anim.properties.rotate {
330                transforms.push(format!("rotate({}deg)", format_num(rot)));
331            }
332            props.push(format!("transform: {}", transforms.join(" ")));
333        } else {
334            let mut transforms = Vec::new();
335            if let Some((tx, ty)) = anim.properties.translate {
336                transforms.push(format!(
337                    "translate({}px, {}px)",
338                    format_num(tx),
339                    format_num(ty)
340                ));
341            }
342            if let Some(rot) = anim.properties.rotate {
343                transforms.push(format!("rotate({}deg)", format_num(rot)));
344            }
345            if !transforms.is_empty() {
346                props.push(format!("transform: {}", transforms.join(" ")));
347            }
348        }
349
350        if !props.is_empty() {
351            let mut rule = String::new();
352            let _ = writeln!(rule, ".{}{} {{", class_name, pseudo_class);
353            let _ = writeln!(
354                rule,
355                "  transition: all {}s {};",
356                format_num_f64(duration_s),
357                easing
358            );
359            for prop in &props {
360                let _ = writeln!(rule, "  {};", prop);
361            }
362            rule.push('}');
363            css_rules.push(rule);
364        }
365    }
366}
367
368/// Convert a Paint to a CSS color/gradient value.
369fn paint_to_css(paint: &Paint) -> String {
370    match paint {
371        Paint::Solid(c) => color_to_css(c),
372        Paint::LinearGradient { angle, stops } => {
373            let mut parts = vec![format!("{}deg", angle)];
374            for stop in stops {
375                parts.push(format!(
376                    "{} {}%",
377                    color_to_css(&stop.color),
378                    (stop.offset * 100.0) as u32
379                ));
380            }
381            format!("linear-gradient({})", parts.join(", "))
382        }
383        Paint::RadialGradient { stops } => {
384            let parts: Vec<String> = stops
385                .iter()
386                .map(|s| format!("{} {}%", color_to_css(&s.color), (s.offset * 100.0) as u32))
387                .collect();
388            format!("radial-gradient(circle, {})", parts.join(", "))
389        }
390    }
391}
392
393/// Convert a Color to a CSS rgba/hex string.
394fn color_to_css(c: &Color) -> String {
395    if (c.a - 1.0).abs() < 0.001 {
396        c.to_hex()
397    } else {
398        format!(
399            "rgba({}, {}, {}, {})",
400            (c.r * 255.0).round() as u8,
401            (c.g * 255.0).round() as u8,
402            (c.b * 255.0).round() as u8,
403            format_num(c.a),
404        )
405    }
406}
407
408/// Convert path commands to an inline SVG element.
409fn path_to_svg(commands: &[PathCmd], b: &ResolvedBounds) -> String {
410    let mut d = String::new();
411    for cmd in commands {
412        match cmd {
413            PathCmd::MoveTo(x, y) => {
414                let _ = write!(d, "M {} {} ", format_num(*x - b.x), format_num(*y - b.y));
415            }
416            PathCmd::LineTo(x, y) => {
417                let _ = write!(d, "L {} {} ", format_num(*x - b.x), format_num(*y - b.y));
418            }
419            PathCmd::QuadTo(cx, cy, x, y) => {
420                let _ = write!(
421                    d,
422                    "Q {} {} {} {} ",
423                    format_num(*cx - b.x),
424                    format_num(*cy - b.y),
425                    format_num(*x - b.x),
426                    format_num(*y - b.y)
427                );
428            }
429            PathCmd::CubicTo(c1x, c1y, c2x, c2y, x, y) => {
430                let _ = write!(
431                    d,
432                    "C {} {} {} {} {} {} ",
433                    format_num(*c1x - b.x),
434                    format_num(*c1y - b.y),
435                    format_num(*c2x - b.x),
436                    format_num(*c2y - b.y),
437                    format_num(*x - b.x),
438                    format_num(*y - b.y)
439                );
440            }
441            PathCmd::Close => {
442                d.push_str("Z ");
443            }
444        }
445    }
446
447    format!(
448        r#"<svg width="{}" height="{}" viewBox="0 0 {} {}" xmlns="http://www.w3.org/2000/svg"><path d="{}" fill="none" stroke="currentColor" stroke-width="2"/></svg>"#,
449        format_num(b.width),
450        format_num(b.height),
451        format_num(b.width),
452        format_num(b.height),
453        d.trim(),
454    )
455}
456
457/// Build the complete HTML document from elements and CSS rules.
458fn build_html_document(elements: &[HtmlElement], css_rules: &[String]) -> String {
459    let mut html = String::with_capacity(4096);
460
461    html.push_str("<!DOCTYPE html>\n");
462    html.push_str("<html lang=\"en\">\n<head>\n");
463    html.push_str("  <meta charset=\"UTF-8\">\n");
464    html.push_str("  <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n");
465    html.push_str("  <title>Fast Draft Export</title>\n");
466    html.push_str("  <link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">\n");
467    html.push_str("  <link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin>\n");
468
469    // Collect unique font families
470    let mut fonts = Vec::new();
471    collect_fonts_from_elements(elements, &mut fonts);
472    fonts.sort();
473    fonts.dedup();
474
475    if !fonts.is_empty() {
476        let font_params: Vec<String> = fonts
477            .iter()
478            .map(|f| format!("family={}", f.replace(' ', "+")))
479            .collect();
480        let _ = writeln!(
481            html,
482            "  <link href=\"https://fonts.googleapis.com/css2?{}&display=swap\" rel=\"stylesheet\">",
483            font_params.join("&")
484        );
485    }
486
487    html.push_str("  <style>\n");
488    html.push_str("    * { margin: 0; padding: 0; box-sizing: border-box; }\n");
489    html.push_str(
490        "    body { min-height: 100vh; position: relative; font-family: 'Inter', sans-serif; }\n",
491    );
492    html.push_str("    .fd-canvas { position: relative; width: 100%; min-height: 100vh; }\n");
493
494    // Write animation CSS rules
495    for rule in css_rules {
496        let _ = writeln!(html, "    {}", rule);
497    }
498
499    html.push_str("  </style>\n");
500    html.push_str("</head>\n<body>\n");
501    html.push_str("  <div class=\"fd-canvas\">\n");
502
503    for element in elements {
504        write_html_element(&mut html, element, 2);
505    }
506
507    html.push_str("  </div>\n");
508    html.push_str("</body>\n</html>\n");
509    html
510}
511
512/// Collect unique font families from elements recursively.
513fn collect_fonts_from_elements(elements: &[HtmlElement], fonts: &mut Vec<String>) {
514    for el in elements {
515        // Extract font-family from inline style
516        if let Some(start) = el.inline_style.find("font-family: '") {
517            let rest = &el.inline_style[start + 14..];
518            if let Some(end) = rest.find('\'') {
519                let family = &rest[..end];
520                fonts.push(family.to_string());
521            }
522        }
523        collect_fonts_from_elements(&el.children, fonts);
524    }
525}
526
527/// Write a single HTML element with indentation.
528fn write_html_element(out: &mut String, el: &HtmlElement, indent: usize) {
529    let pad = "    ".repeat(indent);
530
531    if let Some(ref svg) = el.svg_content {
532        let _ = writeln!(
533            out,
534            "{}<div class=\"{}\" style=\"{}\">",
535            pad, el.css_class, el.inline_style
536        );
537        let _ = writeln!(out, "{}  {}", pad, svg);
538        let _ = writeln!(out, "{}</div>", pad);
539        return;
540    }
541
542    let has_children = !el.children.is_empty();
543    let has_content = !el.content.is_empty();
544
545    if !has_children && !has_content {
546        let _ = writeln!(
547            out,
548            "{}<{} class=\"{}\" style=\"{}\"></{}>",
549            pad, el.tag, el.css_class, el.inline_style, el.tag
550        );
551    } else if has_content && !has_children {
552        let _ = writeln!(
553            out,
554            "{}<{} class=\"{}\" style=\"{}\">{}</{}>",
555            pad, el.tag, el.css_class, el.inline_style, el.content, el.tag
556        );
557    } else {
558        let _ = writeln!(
559            out,
560            "{}<{} class=\"{}\" style=\"{}\">",
561            pad, el.tag, el.css_class, el.inline_style
562        );
563        if has_content {
564            let _ = writeln!(out, "{}  {}", pad, el.content);
565        }
566        for child in &el.children {
567            write_html_element(out, child, indent + 1);
568        }
569        let _ = writeln!(out, "{}</{}>", pad, el.tag);
570    }
571}
572
573/// Format a float: strip trailing zeros, integers without decimal.
574fn format_num(v: f32) -> String {
575    if v == v.floor() && v.abs() < 1e9 {
576        format!("{}", v as i64)
577    } else {
578        let s = format!("{:.2}", v);
579        s.trim_end_matches('0').trim_end_matches('.').to_string()
580    }
581}
582
583/// Format a f64 similarly.
584fn format_num_f64(v: f64) -> String {
585    if v == v.floor() && v.abs() < 1e9 {
586        format!("{}", v as i64)
587    } else {
588        let s = format!("{:.2}", v);
589        s.trim_end_matches('0').trim_end_matches('.').to_string()
590    }
591}
592
593// ─── Tests ───────────────────────────────────────────────────────────────
594
595#[cfg(test)]
596mod tests {
597    use super::*;
598    use crate::parser::parse_document;
599
600    fn parse_and_resolve(input: &str) -> (SceneGraph, HashMap<NodeIndex, ResolvedBounds>) {
601        let graph = parse_document(input).expect("test parse failed");
602        let viewport = crate::layout::Viewport {
603            width: 800.0,
604            height: 600.0,
605        };
606        let bounds = crate::layout::resolve_layout(&graph, viewport);
607        (graph, bounds)
608    }
609
610    #[test]
611    fn export_html_rect() {
612        let (graph, bounds) = parse_and_resolve("rect @box { w: 100 h: 60 fill: #FF0000 }");
613        let html = export_html(&graph, &bounds, &[]);
614
615        assert!(html.contains("<!DOCTYPE html>"));
616        assert!(html.contains("<div"));
617        assert!(html.contains("width: 100px"));
618        assert!(html.contains("height: 60px"));
619        assert!(html.contains("background: #FF0000"));
620    }
621
622    #[test]
623    fn export_html_ellipse() {
624        let (graph, bounds) = parse_and_resolve("ellipse @circle { w: 80 h: 80 fill: #00FF00 }");
625        let html = export_html(&graph, &bounds, &[]);
626
627        assert!(html.contains("border-radius: 50%"));
628        assert!(html.contains("background: #00FF00"));
629    }
630
631    #[test]
632    fn export_html_text() {
633        let (graph, bounds) = parse_and_resolve("text @label \"Hello World\" {}");
634        let html = export_html(&graph, &bounds, &[]);
635
636        assert!(html.contains("<p"));
637        assert!(html.contains("Hello World"));
638    }
639
640    #[test]
641    fn export_html_text_with_font() {
642        let (graph, bounds) =
643            parse_and_resolve("text @title \"Dashboard\" { font: \"Inter\" bold 24 }");
644        let html = export_html(&graph, &bounds, &[]);
645
646        assert!(html.contains("font-family: 'Inter'"));
647        assert!(html.contains("font-size: 24px"));
648        assert!(html.contains("font-weight: 700"));
649        assert!(html.contains("Dashboard"));
650    }
651
652    #[test]
653    fn export_html_selection() {
654        let input = "rect @a { w: 100 h: 50 }\nrect @b { w: 200 h: 80 }";
655        let (graph, bounds) = parse_and_resolve(input);
656        let html = export_html(&graph, &bounds, &["a".to_string()]);
657
658        // Should have only one div with width: 100px
659        assert!(html.contains("width: 100px"));
660        assert!(!html.contains("width: 200px"));
661    }
662
663    #[test]
664    fn export_html_empty_graph() {
665        let graph = SceneGraph::new();
666        let bounds = HashMap::new();
667        let html = export_html(&graph, &bounds, &[]);
668
669        assert!(html.contains("<!DOCTYPE html>"));
670        assert!(html.contains("<div class=\"fd-canvas\">"));
671    }
672
673    #[test]
674    fn export_html_corner_radius() {
675        let (graph, bounds) = parse_and_resolve("rect @r { w: 100 h: 50 corner: 12 }");
676        let html = export_html(&graph, &bounds, &[]);
677
678        assert!(html.contains("border-radius: 12px"));
679    }
680
681    #[test]
682    fn export_html_opacity() {
683        let (graph, bounds) = parse_and_resolve("rect @r { w: 100 h: 50 opacity: 0.5 }");
684        let html = export_html(&graph, &bounds, &[]);
685
686        assert!(html.contains("opacity: 0.5"));
687    }
688
689    #[test]
690    fn export_html_shadow() {
691        let (graph, bounds) =
692            parse_and_resolve("rect @r { w: 100 h: 50 shadow: (4,4,8,#00000040) }");
693        let html = export_html(&graph, &bounds, &[]);
694
695        assert!(html.contains("box-shadow:"));
696    }
697
698    #[test]
699    fn export_html_stroke() {
700        let (graph, bounds) = parse_and_resolve("rect @r { w: 100 h: 50 stroke: #333 2 }");
701        let html = export_html(&graph, &bounds, &[]);
702
703        assert!(html.contains("border:"));
704        assert!(html.contains("#333333"));
705    }
706
707    #[test]
708    fn export_html_nested_frame() {
709        let input = r#"
710frame @container { w: 400 h: 300 fill: #F0F0F0
711  rect @child { w: 100 h: 50 fill: #FF0000 }
712}
713"#;
714        let (graph, bounds) = parse_and_resolve(input);
715        let html = export_html(&graph, &bounds, &[]);
716
717        // Parent div should contain child div
718        assert!(html.contains("fd-canvas"));
719        // Both elements should be present
720        let div_count = html.matches("<div").count();
721        assert!(
722            div_count >= 3,
723            "Expected at least 3 divs (canvas + frame + child), got {}",
724            div_count
725        );
726    }
727
728    #[test]
729    fn export_html_hover_animation() {
730        let input = r#"
731rect @btn { w: 120 h: 40 fill: #6C5CE7
732  when :hover {
733    fill: #A29BFE
734    ease: ease_out 200
735  }
736}
737"#;
738        let (graph, bounds) = parse_and_resolve(input);
739        let html = export_html(&graph, &bounds, &[]);
740
741        // Should have a :hover CSS rule
742        assert!(html.contains(":hover"));
743        assert!(html.contains("background:"));
744        assert!(html.contains("transition:"));
745    }
746
747    #[test]
748    fn export_html_google_fonts() {
749        let (graph, bounds) = parse_and_resolve("text @t \"Hi\" { font: \"Roboto\" 16 }");
750        let html = export_html(&graph, &bounds, &[]);
751
752        assert!(html.contains("fonts.googleapis.com"));
753        assert!(html.contains("Roboto"));
754    }
755
756    #[test]
757    fn export_html_gradient() {
758        let (graph, bounds) = parse_and_resolve(
759            "rect @g { w: 200 h: 100 fill: linear(90deg, #FF0000 0, #0000FF 1) }",
760        );
761        let html = export_html(&graph, &bounds, &[]);
762
763        assert!(html.contains("linear-gradient"));
764    }
765
766    #[test]
767    fn export_html_valid_structure() {
768        let input = r#"
769rect @card { w: 200 h: 150 fill: #3498DB corner: 12 }
770ellipse @avatar { w: 60 h: 60 fill: #E74C3C }
771text @title "Dashboard" { font: "Inter" bold 24 }
772"#;
773        let (graph, bounds) = parse_and_resolve(input);
774        let html = export_html(&graph, &bounds, &[]);
775
776        assert!(html.starts_with("<!DOCTYPE html>"));
777        assert!(html.contains("</html>"));
778        assert!(html.contains("<head>"));
779        assert!(html.contains("</head>"));
780        assert!(html.contains("<body>"));
781        assert!(html.contains("</body>"));
782    }
783}