Skip to main content

fd_core/
emitter.rs

1//! Emitter: SceneGraph → FD text format.
2//!
3//! Produces minimal, token-efficient output that round-trips through the parser.
4
5use crate::id::NodeId;
6use crate::model::*;
7use petgraph::graph::NodeIndex;
8use std::fmt::Write;
9
10/// Emit a `SceneGraph` as an FD text document.
11#[must_use]
12pub fn emit_document(graph: &SceneGraph) -> String {
13    let mut out = String::with_capacity(1024);
14
15    // Count sections to decide if separators add value
16    let has_imports = !graph.imports.is_empty();
17    let has_styles = !graph.styles.is_empty();
18    let children = graph.children(graph.root);
19    let has_constraints = graph.graph.node_indices().any(|idx| {
20        graph.graph[idx]
21            .constraints
22            .iter()
23            .any(|c| !matches!(c, Constraint::Position { .. }))
24    });
25    let has_edges = !graph.edges.is_empty();
26    let section_count =
27        has_imports as u8 + has_styles as u8 + has_constraints as u8 + has_edges as u8;
28    let use_separators = section_count >= 2;
29
30    // Emit imports
31    for import in &graph.imports {
32        let _ = writeln!(out, "import \"{}\" as {}", import.path, import.namespace);
33    }
34    if has_imports {
35        out.push('\n');
36    }
37
38    // Emit style definitions
39    if use_separators && has_styles {
40        out.push_str("# ─── Themes ───\n\n");
41    }
42    let mut styles: Vec<_> = graph.styles.iter().collect();
43    styles.sort_by_key(|(id, _)| id.as_str().to_string());
44    for (name, style) in &styles {
45        emit_style_block(&mut out, name, style, 0);
46        out.push('\n');
47    }
48
49    // Emit root's children (node tree)
50    if use_separators && !children.is_empty() {
51        out.push_str("# ─── Layout ───\n\n");
52    }
53    for child_idx in &children {
54        emit_node(&mut out, graph, *child_idx, 0);
55        out.push('\n');
56    }
57
58    // Emit top-level constraints (skip Position — emitted inline as x:/y:)
59    if use_separators && has_constraints {
60        out.push_str("# ─── Constraints ───\n\n");
61    }
62    for idx in graph.graph.node_indices() {
63        let node = &graph.graph[idx];
64        for constraint in &node.constraints {
65            if matches!(constraint, Constraint::Position { .. }) {
66                continue; // emitted inline inside node block
67            }
68            emit_constraint(&mut out, &node.id, constraint);
69        }
70    }
71
72    // Emit edges
73    if use_separators && has_edges {
74        if has_constraints {
75            out.push('\n');
76        }
77        out.push_str("# ─── Flows ───\n\n");
78    }
79    for edge in &graph.edges {
80        emit_edge(&mut out, edge);
81    }
82
83    out
84}
85
86fn indent(out: &mut String, depth: usize) {
87    for _ in 0..depth {
88        out.push_str("  ");
89    }
90}
91
92fn emit_style_block(out: &mut String, name: &NodeId, style: &Style, depth: usize) {
93    indent(out, depth);
94    writeln!(out, "theme {} {{", name.as_str()).unwrap();
95
96    if let Some(ref fill) = style.fill {
97        emit_paint_prop(out, "fill", fill, depth + 1);
98    }
99    if let Some(ref font) = style.font {
100        emit_font_prop(out, font, depth + 1);
101    }
102    if let Some(radius) = style.corner_radius {
103        indent(out, depth + 1);
104        writeln!(out, "corner: {}", format_num(radius)).unwrap();
105    }
106    if let Some(opacity) = style.opacity {
107        indent(out, depth + 1);
108        writeln!(out, "opacity: {}", format_num(opacity)).unwrap();
109    }
110    if let Some(ref shadow) = style.shadow {
111        indent(out, depth + 1);
112        writeln!(
113            out,
114            "shadow: ({},{},{},{})",
115            format_num(shadow.offset_x),
116            format_num(shadow.offset_y),
117            format_num(shadow.blur),
118            shadow.color.to_hex()
119        )
120        .unwrap();
121    }
122    // Text alignment
123    if style.text_align.is_some() || style.text_valign.is_some() {
124        let h = match style.text_align {
125            Some(TextAlign::Left) => "left",
126            Some(TextAlign::Right) => "right",
127            _ => "center",
128        };
129        let v = match style.text_valign {
130            Some(TextVAlign::Top) => "top",
131            Some(TextVAlign::Bottom) => "bottom",
132            _ => "middle",
133        };
134        indent(out, depth + 1);
135        writeln!(out, "align: {h} {v}").unwrap();
136    }
137
138    indent(out, depth);
139    out.push_str("}\n");
140}
141
142fn emit_node(out: &mut String, graph: &SceneGraph, idx: NodeIndex, depth: usize) {
143    let node = &graph.graph[idx];
144
145    // Emit preserved `# comment` lines before the node declaration
146    for comment in &node.comments {
147        indent(out, depth);
148        writeln!(out, "# {comment}").unwrap();
149    }
150
151    indent(out, depth);
152
153    // Node kind keyword + optional @id + optional inline text
154    match &node.kind {
155        NodeKind::Root => return,
156        NodeKind::Generic => write!(out, "@{}", node.id.as_str()).unwrap(),
157        NodeKind::Group { .. } => write!(out, "group @{}", node.id.as_str()).unwrap(),
158        NodeKind::Frame { .. } => write!(out, "frame @{}", node.id.as_str()).unwrap(),
159        NodeKind::Rect { .. } => write!(out, "rect @{}", node.id.as_str()).unwrap(),
160        NodeKind::Ellipse { .. } => write!(out, "ellipse @{}", node.id.as_str()).unwrap(),
161        NodeKind::Path { .. } => write!(out, "path @{}", node.id.as_str()).unwrap(),
162        NodeKind::Text { content } => {
163            write!(out, "text @{} \"{}\"", node.id.as_str(), content).unwrap();
164        }
165    }
166
167    out.push_str(" {\n");
168
169    // Annotations (spec block)
170    emit_annotations(out, &node.annotations, depth + 1);
171
172    // Children — emitted right after spec so the structural skeleton
173    // is visible first. Visual styling comes at the tail for clean folding.
174    let children = graph.children(idx);
175    for child_idx in &children {
176        emit_node(out, graph, *child_idx, depth + 1);
177    }
178
179    // Layout mode (for groups)
180    if let NodeKind::Group { layout } = &node.kind {
181        match layout {
182            LayoutMode::Free => {}
183            LayoutMode::Column { gap, pad } => {
184                indent(out, depth + 1);
185                writeln!(
186                    out,
187                    "layout: column gap={} pad={}",
188                    format_num(*gap),
189                    format_num(*pad)
190                )
191                .unwrap();
192            }
193            LayoutMode::Row { gap, pad } => {
194                indent(out, depth + 1);
195                writeln!(
196                    out,
197                    "layout: row gap={} pad={}",
198                    format_num(*gap),
199                    format_num(*pad)
200                )
201                .unwrap();
202            }
203            LayoutMode::Grid { cols, gap, pad } => {
204                indent(out, depth + 1);
205                writeln!(
206                    out,
207                    "layout: grid cols={cols} gap={} pad={}",
208                    format_num(*gap),
209                    format_num(*pad)
210                )
211                .unwrap();
212            }
213        }
214    }
215
216    // Layout mode (for frames)
217    if let NodeKind::Frame { layout, .. } = &node.kind {
218        match layout {
219            LayoutMode::Free => {}
220            LayoutMode::Column { gap, pad } => {
221                indent(out, depth + 1);
222                writeln!(
223                    out,
224                    "layout: column gap={} pad={}",
225                    format_num(*gap),
226                    format_num(*pad)
227                )
228                .unwrap();
229            }
230            LayoutMode::Row { gap, pad } => {
231                indent(out, depth + 1);
232                writeln!(
233                    out,
234                    "layout: row gap={} pad={}",
235                    format_num(*gap),
236                    format_num(*pad)
237                )
238                .unwrap();
239            }
240            LayoutMode::Grid { cols, gap, pad } => {
241                indent(out, depth + 1);
242                writeln!(
243                    out,
244                    "layout: grid cols={cols} gap={} pad={}",
245                    format_num(*gap),
246                    format_num(*pad)
247                )
248                .unwrap();
249            }
250        }
251    }
252
253    // Dimensions
254    match &node.kind {
255        NodeKind::Rect { width, height } => {
256            indent(out, depth + 1);
257            writeln!(out, "w: {} h: {}", format_num(*width), format_num(*height)).unwrap();
258        }
259        NodeKind::Frame { width, height, .. } => {
260            indent(out, depth + 1);
261            writeln!(out, "w: {} h: {}", format_num(*width), format_num(*height)).unwrap();
262        }
263        NodeKind::Ellipse { rx, ry } => {
264            indent(out, depth + 1);
265            writeln!(out, "w: {} h: {}", format_num(*rx), format_num(*ry)).unwrap();
266        }
267        _ => {}
268    }
269
270    // Clip property (for frames only)
271    if let NodeKind::Frame { clip: true, .. } = &node.kind {
272        indent(out, depth + 1);
273        writeln!(out, "clip: true").unwrap();
274    }
275
276    // Style references
277    for style_ref in &node.use_styles {
278        indent(out, depth + 1);
279        writeln!(out, "use: {}", style_ref.as_str()).unwrap();
280    }
281
282    // Inline style properties
283    if let Some(ref fill) = node.style.fill {
284        emit_paint_prop(out, "fill", fill, depth + 1);
285    }
286    if let Some(ref stroke) = node.style.stroke {
287        indent(out, depth + 1);
288        match &stroke.paint {
289            Paint::Solid(c) => {
290                writeln!(out, "stroke: {} {}", c.to_hex(), format_num(stroke.width)).unwrap()
291            }
292            _ => writeln!(out, "stroke: #000 {}", format_num(stroke.width)).unwrap(),
293        }
294    }
295    if let Some(radius) = node.style.corner_radius {
296        indent(out, depth + 1);
297        writeln!(out, "corner: {}", format_num(radius)).unwrap();
298    }
299    if let Some(ref font) = node.style.font {
300        emit_font_prop(out, font, depth + 1);
301    }
302    if let Some(opacity) = node.style.opacity {
303        indent(out, depth + 1);
304        writeln!(out, "opacity: {}", format_num(opacity)).unwrap();
305    }
306    if let Some(ref shadow) = node.style.shadow {
307        indent(out, depth + 1);
308        writeln!(
309            out,
310            "shadow: ({},{},{},{})",
311            format_num(shadow.offset_x),
312            format_num(shadow.offset_y),
313            format_num(shadow.blur),
314            shadow.color.to_hex()
315        )
316        .unwrap();
317    }
318
319    // Text alignment
320    if node.style.text_align.is_some() || node.style.text_valign.is_some() {
321        let h = match node.style.text_align {
322            Some(TextAlign::Left) => "left",
323            Some(TextAlign::Right) => "right",
324            _ => "center",
325        };
326        let v = match node.style.text_valign {
327            Some(TextVAlign::Top) => "top",
328            Some(TextVAlign::Bottom) => "bottom",
329            _ => "middle",
330        };
331        indent(out, depth + 1);
332        writeln!(out, "align: {h} {v}").unwrap();
333    }
334
335    // Inline position (x: / y:) — emitted here for token efficiency
336    for constraint in &node.constraints {
337        if let Constraint::Position { x, y } = constraint {
338            if *x != 0.0 {
339                indent(out, depth + 1);
340                writeln!(out, "x: {}", format_num(*x)).unwrap();
341            }
342            if *y != 0.0 {
343                indent(out, depth + 1);
344                writeln!(out, "y: {}", format_num(*y)).unwrap();
345            }
346        }
347    }
348
349    // Animations (when blocks)
350    for anim in &node.animations {
351        emit_anim(out, anim, depth + 1);
352    }
353
354    indent(out, depth);
355    out.push_str("}\n");
356}
357
358fn emit_annotations(out: &mut String, annotations: &[Annotation], depth: usize) {
359    if annotations.is_empty() {
360        return;
361    }
362
363    // Single description → inline shorthand: `spec "desc"`
364    if annotations.len() == 1
365        && let Annotation::Description(s) = &annotations[0]
366    {
367        indent(out, depth);
368        writeln!(out, "spec \"{s}\"").unwrap();
369        return;
370    }
371
372    // Multiple annotations → block form: `spec { ... }`
373    indent(out, depth);
374    out.push_str("spec {\n");
375    for ann in annotations {
376        indent(out, depth + 1);
377        match ann {
378            Annotation::Description(s) => writeln!(out, "\"{s}\"").unwrap(),
379            Annotation::Accept(s) => writeln!(out, "accept: \"{s}\"").unwrap(),
380            Annotation::Status(s) => writeln!(out, "status: {s}").unwrap(),
381            Annotation::Priority(s) => writeln!(out, "priority: {s}").unwrap(),
382            Annotation::Tag(s) => writeln!(out, "tag: {s}").unwrap(),
383        }
384    }
385    indent(out, depth);
386    out.push_str("}\n");
387}
388
389fn emit_paint_prop(out: &mut String, name: &str, paint: &Paint, depth: usize) {
390    indent(out, depth);
391    match paint {
392        Paint::Solid(c) => {
393            let hex = c.to_hex();
394            let hint = color_hint(&hex);
395            if hint.is_empty() {
396                writeln!(out, "{name}: {hex}").unwrap();
397            } else {
398                writeln!(out, "{name}: {hex}  # {hint}").unwrap();
399            }
400        }
401        Paint::LinearGradient { angle, stops } => {
402            write!(out, "{name}: linear({}deg", format_num(*angle)).unwrap();
403            for stop in stops {
404                write!(out, ", {} {}", stop.color.to_hex(), format_num(stop.offset)).unwrap();
405            }
406            writeln!(out, ")").unwrap();
407        }
408        Paint::RadialGradient { stops } => {
409            write!(out, "{name}: radial(").unwrap();
410            for (i, stop) in stops.iter().enumerate() {
411                if i > 0 {
412                    write!(out, ", ").unwrap();
413                }
414                write!(out, "{} {}", stop.color.to_hex(), format_num(stop.offset)).unwrap();
415            }
416            writeln!(out, ")").unwrap();
417        }
418    }
419}
420
421fn emit_font_prop(out: &mut String, font: &FontSpec, depth: usize) {
422    indent(out, depth);
423    let weight_str = weight_number_to_name(font.weight);
424    writeln!(
425        out,
426        "font: \"{}\" {} {}",
427        font.family,
428        weight_str,
429        format_num(font.size)
430    )
431    .unwrap();
432}
433
434/// Map numeric font weight to human-readable name.
435fn weight_number_to_name(weight: u16) -> &'static str {
436    match weight {
437        100 => "thin",
438        200 => "extralight",
439        300 => "light",
440        400 => "regular",
441        500 => "medium",
442        600 => "semibold",
443        700 => "bold",
444        800 => "extrabold",
445        900 => "black",
446        _ => "400", // fallback
447    }
448}
449
450/// Classify a hex color into a human-readable hue name.
451fn color_hint(hex: &str) -> &'static str {
452    let hex = hex.trim_start_matches('#');
453    let Some((r, g, b)) = (match hex.len() {
454        3 | 4 => {
455            let r = u8::from_str_radix(&hex[0..1], 16).unwrap_or(0) * 17;
456            let g = u8::from_str_radix(&hex[1..2], 16).unwrap_or(0) * 17;
457            let b = u8::from_str_radix(&hex[2..3], 16).unwrap_or(0) * 17;
458            Some((r, g, b))
459        }
460        6 | 8 => {
461            let r = u8::from_str_radix(&hex[0..2], 16).unwrap_or(0);
462            let g = u8::from_str_radix(&hex[2..4], 16).unwrap_or(0);
463            let b = u8::from_str_radix(&hex[4..6], 16).unwrap_or(0);
464            Some((r, g, b))
465        }
466        _ => None,
467    }) else {
468        return "";
469    };
470
471    // Achromatic check
472    let max = r.max(g).max(b);
473    let min = r.min(g).min(b);
474    let diff = max - min;
475    if diff < 15 {
476        return match max {
477            0..=30 => "black",
478            31..=200 => "gray",
479            _ => "white",
480        };
481    }
482
483    // Hue classification
484    let rf = r as f32;
485    let gf = g as f32;
486    let bf = b as f32;
487    let hue = if max == r {
488        60.0 * (((gf - bf) / diff as f32) % 6.0)
489    } else if max == g {
490        60.0 * (((bf - rf) / diff as f32) + 2.0)
491    } else {
492        60.0 * (((rf - gf) / diff as f32) + 4.0)
493    };
494    let hue = if hue < 0.0 { hue + 360.0 } else { hue };
495
496    match hue as u16 {
497        0..=14 | 346..=360 => "red",
498        15..=39 => "orange",
499        40..=64 => "yellow",
500        65..=79 => "lime",
501        80..=159 => "green",
502        160..=179 => "teal",
503        180..=199 => "cyan",
504        200..=259 => "blue",
505        260..=279 => "purple",
506        280..=319 => "pink",
507        320..=345 => "rose",
508        _ => "",
509    }
510}
511
512fn emit_anim(out: &mut String, anim: &AnimKeyframe, depth: usize) {
513    indent(out, depth);
514    let trigger = match &anim.trigger {
515        AnimTrigger::Hover => "hover",
516        AnimTrigger::Press => "press",
517        AnimTrigger::Enter => "enter",
518        AnimTrigger::Custom(s) => s.as_str(),
519    };
520    writeln!(out, "when :{trigger} {{").unwrap();
521
522    if let Some(ref fill) = anim.properties.fill {
523        emit_paint_prop(out, "fill", fill, depth + 1);
524    }
525    if let Some(opacity) = anim.properties.opacity {
526        indent(out, depth + 1);
527        writeln!(out, "opacity: {}", format_num(opacity)).unwrap();
528    }
529    if let Some(scale) = anim.properties.scale {
530        indent(out, depth + 1);
531        writeln!(out, "scale: {}", format_num(scale)).unwrap();
532    }
533    if let Some(rotate) = anim.properties.rotate {
534        indent(out, depth + 1);
535        writeln!(out, "rotate: {}", format_num(rotate)).unwrap();
536    }
537
538    let ease_name = match &anim.easing {
539        Easing::Linear => "linear",
540        Easing::EaseIn => "ease_in",
541        Easing::EaseOut => "ease_out",
542        Easing::EaseInOut => "ease_in_out",
543        Easing::Spring => "spring",
544        Easing::CubicBezier(_, _, _, _) => "cubic",
545    };
546    indent(out, depth + 1);
547    writeln!(out, "ease: {ease_name} {}ms", anim.duration_ms).unwrap();
548
549    indent(out, depth);
550    out.push_str("}\n");
551}
552
553fn emit_constraint(out: &mut String, node_id: &NodeId, constraint: &Constraint) {
554    match constraint {
555        Constraint::CenterIn(target) => {
556            writeln!(
557                out,
558                "@{} -> center_in: {}",
559                node_id.as_str(),
560                target.as_str()
561            )
562            .unwrap();
563        }
564        Constraint::Offset { from, dx, dy } => {
565            writeln!(
566                out,
567                "@{} -> offset: @{} {}, {}",
568                node_id.as_str(),
569                from.as_str(),
570                format_num(*dx),
571                format_num(*dy)
572            )
573            .unwrap();
574        }
575        Constraint::FillParent { pad } => {
576            writeln!(
577                out,
578                "@{} -> fill_parent: {}",
579                node_id.as_str(),
580                format_num(*pad)
581            )
582            .unwrap();
583        }
584        Constraint::Position { .. } => {
585            // Emitted inline as x: / y: inside node block — skip here
586        }
587    }
588}
589
590fn emit_edge(out: &mut String, edge: &Edge) {
591    writeln!(out, "edge @{} {{", edge.id.as_str()).unwrap();
592
593    // Annotations
594    emit_annotations(out, &edge.annotations, 1);
595
596    // from / to
597    writeln!(out, "  from: @{}", edge.from.as_str()).unwrap();
598    writeln!(out, "  to: @{}", edge.to.as_str()).unwrap();
599
600    // Label
601    if let Some(ref label) = edge.label {
602        writeln!(out, "  label: \"{label}\"").unwrap();
603    }
604
605    // Style references
606    for style_ref in &edge.use_styles {
607        writeln!(out, "  use: {}", style_ref.as_str()).unwrap();
608    }
609
610    // Stroke
611    if let Some(ref stroke) = edge.style.stroke {
612        match &stroke.paint {
613            Paint::Solid(c) => {
614                writeln!(out, "  stroke: {} {}", c.to_hex(), format_num(stroke.width)).unwrap();
615            }
616            _ => {
617                writeln!(out, "  stroke: #000 {}", format_num(stroke.width)).unwrap();
618            }
619        }
620    }
621
622    // Opacity
623    if let Some(opacity) = edge.style.opacity {
624        writeln!(out, "  opacity: {}", format_num(opacity)).unwrap();
625    }
626
627    // Arrow
628    if edge.arrow != ArrowKind::None {
629        let name = match edge.arrow {
630            ArrowKind::None => "none",
631            ArrowKind::Start => "start",
632            ArrowKind::End => "end",
633            ArrowKind::Both => "both",
634        };
635        writeln!(out, "  arrow: {name}").unwrap();
636    }
637
638    // Curve
639    if edge.curve != CurveKind::Straight {
640        let name = match edge.curve {
641            CurveKind::Straight => "straight",
642            CurveKind::Smooth => "smooth",
643            CurveKind::Step => "step",
644        };
645        writeln!(out, "  curve: {name}").unwrap();
646    }
647
648    // Flow animation
649    if let Some(ref flow) = edge.flow {
650        let kind = match flow.kind {
651            FlowKind::Pulse => "pulse",
652            FlowKind::Dash => "dash",
653        };
654        writeln!(out, "  flow: {} {}ms", kind, flow.duration_ms).unwrap();
655    }
656
657    // Trigger animations
658    for anim in &edge.animations {
659        emit_anim(out, anim, 1);
660    }
661
662    out.push_str("}\n");
663}
664
665// ─── Read Modes (filtered emit for AI agents) ────────────────────────────
666
667/// What an AI agent wants to read from the document.
668///
669/// Each mode selectively emits only the properties relevant to a specific
670/// concern, saving 50-80% tokens while preserving structural understanding.
671#[derive(Debug, Clone, Copy, PartialEq, Eq)]
672pub enum ReadMode {
673    /// Full file — no filtering (identical to `emit_document`).
674    Full,
675    /// Node types, `@id`s, parent-child nesting only.
676    Structure,
677    /// Structure + dimensions (`w:`/`h:`) + `layout:` directives + constraints.
678    Layout,
679    /// Structure + themes/styles + `fill:`/`stroke:`/`font:`/`corner:`/`use:` refs.
680    Design,
681    /// Structure + `spec {}` blocks + annotations.
682    Spec,
683    /// Layout + Design + When combined — the full visual story.
684    Visual,
685    /// Structure + `when :trigger { ... }` animation blocks only.
686    When,
687    /// Structure + `edge @id { ... }` blocks.
688    Edges,
689}
690
691/// Emit a `SceneGraph` filtered to show only the properties relevant to `mode`.
692///
693/// - `Full`: identical to `emit_document`.
694/// - `Structure`: node kind + `@id` + children. No styles, dims, anims, specs.
695/// - `Layout`: structure + `w:`/`h:` + `layout:` + constraints (`->`).
696/// - `Design`: structure + themes + `fill:`/`stroke:`/`font:`/`corner:`/`use:`.
697/// - `Spec`: structure + `spec {}` blocks.
698/// - `Visual`: layout + design + when combined.
699/// - `When`: structure + `when :trigger { ... }` blocks.
700/// - `Edges`: structure + `edge @id { ... }` blocks.
701#[must_use]
702pub fn emit_filtered(graph: &SceneGraph, mode: ReadMode) -> String {
703    if mode == ReadMode::Full {
704        return emit_document(graph);
705    }
706
707    let mut out = String::with_capacity(1024);
708
709    let children = graph.children(graph.root);
710    let include_themes = matches!(mode, ReadMode::Design | ReadMode::Visual);
711    let include_constraints = matches!(mode, ReadMode::Layout | ReadMode::Visual);
712    let include_edges = matches!(mode, ReadMode::Edges | ReadMode::Visual);
713
714    // Themes (Design and Visual modes)
715    if include_themes && !graph.styles.is_empty() {
716        let mut styles: Vec<_> = graph.styles.iter().collect();
717        styles.sort_by_key(|(id, _)| id.as_str().to_string());
718        for (name, style) in &styles {
719            emit_style_block(&mut out, name, style, 0);
720            out.push('\n');
721        }
722    }
723
724    // Node tree (always emitted, but with per-mode filtering)
725    for child_idx in &children {
726        emit_node_filtered(&mut out, graph, *child_idx, 0, mode);
727        out.push('\n');
728    }
729
730    // Constraints (Layout and Visual modes)
731    if include_constraints {
732        for idx in graph.graph.node_indices() {
733            let node = &graph.graph[idx];
734            for constraint in &node.constraints {
735                if matches!(constraint, Constraint::Position { .. }) {
736                    continue;
737                }
738                emit_constraint(&mut out, &node.id, constraint);
739            }
740        }
741    }
742
743    // Edges (Edges and Visual modes)
744    if include_edges {
745        for edge in &graph.edges {
746            emit_edge(&mut out, edge);
747            out.push('\n');
748        }
749    }
750
751    out
752}
753
754/// Emit a single node with mode-based property filtering.
755fn emit_node_filtered(
756    out: &mut String,
757    graph: &SceneGraph,
758    idx: NodeIndex,
759    depth: usize,
760    mode: ReadMode,
761) {
762    let node = &graph.graph[idx];
763
764    if matches!(node.kind, NodeKind::Root) {
765        return;
766    }
767
768    indent(out, depth);
769
770    // Node kind + @id (always emitted)
771    match &node.kind {
772        NodeKind::Root => return,
773        NodeKind::Generic => write!(out, "@{}", node.id.as_str()).unwrap(),
774        NodeKind::Group { .. } => write!(out, "group @{}", node.id.as_str()).unwrap(),
775        NodeKind::Frame { .. } => write!(out, "frame @{}", node.id.as_str()).unwrap(),
776        NodeKind::Rect { .. } => write!(out, "rect @{}", node.id.as_str()).unwrap(),
777        NodeKind::Ellipse { .. } => write!(out, "ellipse @{}", node.id.as_str()).unwrap(),
778        NodeKind::Path { .. } => write!(out, "path @{}", node.id.as_str()).unwrap(),
779        NodeKind::Text { content } => {
780            write!(out, "text @{} \"{}\"", node.id.as_str(), content).unwrap();
781        }
782    }
783
784    out.push_str(" {\n");
785
786    // Spec annotations (Spec mode only)
787    if mode == ReadMode::Spec {
788        emit_annotations(out, &node.annotations, depth + 1);
789    }
790
791    // Children (always recurse)
792    let children = graph.children(idx);
793    for child_idx in &children {
794        emit_node_filtered(out, graph, *child_idx, depth + 1, mode);
795    }
796
797    // Layout directives (Layout and Visual modes)
798    if matches!(mode, ReadMode::Layout | ReadMode::Visual) {
799        emit_layout_mode_filtered(out, &node.kind, depth + 1);
800    }
801
802    // Dimensions (Layout and Visual modes)
803    if matches!(mode, ReadMode::Layout | ReadMode::Visual) {
804        emit_dimensions_filtered(out, &node.kind, depth + 1);
805    }
806
807    // Style properties (Design and Visual modes)
808    if matches!(mode, ReadMode::Design | ReadMode::Visual) {
809        for style_ref in &node.use_styles {
810            indent(out, depth + 1);
811            writeln!(out, "use: {}", style_ref.as_str()).unwrap();
812        }
813        if let Some(ref fill) = node.style.fill {
814            emit_paint_prop(out, "fill", fill, depth + 1);
815        }
816        if let Some(ref stroke) = node.style.stroke {
817            indent(out, depth + 1);
818            match &stroke.paint {
819                Paint::Solid(c) => {
820                    writeln!(out, "stroke: {} {}", c.to_hex(), format_num(stroke.width)).unwrap();
821                }
822                _ => writeln!(out, "stroke: #000 {}", format_num(stroke.width)).unwrap(),
823            }
824        }
825        if let Some(radius) = node.style.corner_radius {
826            indent(out, depth + 1);
827            writeln!(out, "corner: {}", format_num(radius)).unwrap();
828        }
829        if let Some(ref font) = node.style.font {
830            emit_font_prop(out, font, depth + 1);
831        }
832        if let Some(opacity) = node.style.opacity {
833            indent(out, depth + 1);
834            writeln!(out, "opacity: {}", format_num(opacity)).unwrap();
835        }
836    }
837
838    // Inline position (Layout and Visual modes)
839    if matches!(mode, ReadMode::Layout | ReadMode::Visual) {
840        for constraint in &node.constraints {
841            if let Constraint::Position { x, y } = constraint {
842                if *x != 0.0 {
843                    indent(out, depth + 1);
844                    writeln!(out, "x: {}", format_num(*x)).unwrap();
845                }
846                if *y != 0.0 {
847                    indent(out, depth + 1);
848                    writeln!(out, "y: {}", format_num(*y)).unwrap();
849                }
850            }
851        }
852    }
853
854    // Animations / when blocks (When and Visual modes)
855    if matches!(mode, ReadMode::When | ReadMode::Visual) {
856        for anim in &node.animations {
857            emit_anim(out, anim, depth + 1);
858        }
859    }
860
861    indent(out, depth);
862    out.push_str("}\n");
863}
864
865/// Emit layout mode directive for groups and frames (filtered path).
866fn emit_layout_mode_filtered(out: &mut String, kind: &NodeKind, depth: usize) {
867    let layout = match kind {
868        NodeKind::Group { layout } | NodeKind::Frame { layout, .. } => layout,
869        _ => return,
870    };
871    match layout {
872        LayoutMode::Free => {}
873        LayoutMode::Column { gap, pad } => {
874            indent(out, depth);
875            writeln!(
876                out,
877                "layout: column gap={} pad={}",
878                format_num(*gap),
879                format_num(*pad)
880            )
881            .unwrap();
882        }
883        LayoutMode::Row { gap, pad } => {
884            indent(out, depth);
885            writeln!(
886                out,
887                "layout: row gap={} pad={}",
888                format_num(*gap),
889                format_num(*pad)
890            )
891            .unwrap();
892        }
893        LayoutMode::Grid { cols, gap, pad } => {
894            indent(out, depth);
895            writeln!(
896                out,
897                "layout: grid cols={cols} gap={} pad={}",
898                format_num(*gap),
899                format_num(*pad)
900            )
901            .unwrap();
902        }
903    }
904}
905
906/// Emit dimension properties (w/h) for sized nodes (filtered path).
907fn emit_dimensions_filtered(out: &mut String, kind: &NodeKind, depth: usize) {
908    match kind {
909        NodeKind::Rect { width, height } | NodeKind::Frame { width, height, .. } => {
910            indent(out, depth);
911            writeln!(out, "w: {} h: {}", format_num(*width), format_num(*height)).unwrap();
912        }
913        NodeKind::Ellipse { rx, ry } => {
914            indent(out, depth);
915            writeln!(out, "w: {} h: {}", format_num(*rx), format_num(*ry)).unwrap();
916        }
917        _ => {}
918    }
919}
920
921// ─── Spec Markdown Export ─────────────────────────────────────────────────
922
923/// Emit a `SceneGraph` as a markdown spec document.
924///
925/// Extracts only `@id` names, `spec { ... }` annotations, hierarchy, and edges —
926/// all visual properties (fill, stroke, dimensions, animations) are omitted.
927/// Intended for PM-facing spec reports.
928#[must_use]
929pub fn emit_spec_markdown(graph: &SceneGraph, title: &str) -> String {
930    let mut out = String::with_capacity(512);
931    writeln!(out, "# Spec: {title}\n").unwrap();
932
933    // Emit root's children
934    let children = graph.children(graph.root);
935    for child_idx in &children {
936        emit_spec_node(&mut out, graph, *child_idx, 2);
937    }
938
939    // Emit edges as flow descriptions
940    if !graph.edges.is_empty() {
941        out.push_str("\n---\n\n## Flows\n\n");
942        for edge in &graph.edges {
943            write!(
944                out,
945                "- **@{}** → **@{}**",
946                edge.from.as_str(),
947                edge.to.as_str()
948            )
949            .unwrap();
950            if let Some(ref label) = edge.label {
951                write!(out, " — {label}").unwrap();
952            }
953            out.push('\n');
954            emit_spec_annotations(&mut out, &edge.annotations, "  ");
955        }
956    }
957
958    out
959}
960
961fn emit_spec_node(out: &mut String, graph: &SceneGraph, idx: NodeIndex, heading_level: usize) {
962    let node = &graph.graph[idx];
963
964    // Skip nodes with no annotations and no annotated children
965    let has_annotations = !node.annotations.is_empty();
966    let children = graph.children(idx);
967    let has_annotated_children = children
968        .iter()
969        .any(|c| has_annotations_recursive(graph, *c));
970
971    if !has_annotations && !has_annotated_children {
972        return;
973    }
974
975    // Heading: ## @node_id (kind)
976    let hashes = "#".repeat(heading_level.min(6));
977    let kind_label = match &node.kind {
978        NodeKind::Root => return,
979        NodeKind::Generic => "spec",
980        NodeKind::Group { .. } => "group",
981        NodeKind::Frame { .. } => "frame",
982        NodeKind::Rect { .. } => "rect",
983        NodeKind::Ellipse { .. } => "ellipse",
984        NodeKind::Path { .. } => "path",
985        NodeKind::Text { .. } => "text",
986    };
987    writeln!(out, "{hashes} @{} `{kind_label}`\n", node.id.as_str()).unwrap();
988
989    // Annotation details
990    emit_spec_annotations(out, &node.annotations, "");
991
992    // Children (recurse with deeper heading level)
993    for child_idx in &children {
994        emit_spec_node(out, graph, *child_idx, heading_level + 1);
995    }
996}
997
998fn has_annotations_recursive(graph: &SceneGraph, idx: NodeIndex) -> bool {
999    let node = &graph.graph[idx];
1000    if !node.annotations.is_empty() {
1001        return true;
1002    }
1003    graph
1004        .children(idx)
1005        .iter()
1006        .any(|c| has_annotations_recursive(graph, *c))
1007}
1008
1009fn emit_spec_annotations(out: &mut String, annotations: &[Annotation], prefix: &str) {
1010    for ann in annotations {
1011        match ann {
1012            Annotation::Description(s) => writeln!(out, "{prefix}> {s}").unwrap(),
1013            Annotation::Accept(s) => writeln!(out, "{prefix}- [ ] {s}").unwrap(),
1014            Annotation::Status(s) => writeln!(out, "{prefix}- **Status:** {s}").unwrap(),
1015            Annotation::Priority(s) => writeln!(out, "{prefix}- **Priority:** {s}").unwrap(),
1016            Annotation::Tag(s) => writeln!(out, "{prefix}- **Tag:** {s}").unwrap(),
1017        }
1018    }
1019    if !annotations.is_empty() {
1020        out.push('\n');
1021    }
1022}
1023
1024/// Format a float without trailing zeros for compact output.
1025fn format_num(n: f32) -> String {
1026    if n == n.floor() {
1027        format!("{}", n as i32)
1028    } else {
1029        format!("{n:.2}")
1030            .trim_end_matches('0')
1031            .trim_end_matches('.')
1032            .to_string()
1033    }
1034}
1035
1036#[cfg(test)]
1037mod tests {
1038    use super::*;
1039    use crate::parser::parse_document;
1040
1041    #[test]
1042    fn roundtrip_simple() {
1043        let input = r#"
1044rect @box {
1045  w: 100
1046  h: 50
1047  fill: #FF0000
1048}
1049"#;
1050        let graph = parse_document(input).unwrap();
1051        let output = emit_document(&graph);
1052
1053        // Re-parse the emitted output
1054        let graph2 = parse_document(&output).expect("re-parse of emitted output failed");
1055        let node2 = graph2.get_by_id(NodeId::intern("box")).unwrap();
1056
1057        match &node2.kind {
1058            NodeKind::Rect { width, height } => {
1059                assert_eq!(*width, 100.0);
1060                assert_eq!(*height, 50.0);
1061            }
1062            _ => panic!("expected Rect"),
1063        }
1064    }
1065
1066    #[test]
1067    fn roundtrip_ellipse() {
1068        let input = r#"
1069ellipse @dot {
1070  w: 40 h: 40
1071  fill: #00FF00
1072}
1073"#;
1074        let graph = parse_document(input).unwrap();
1075        let output = emit_document(&graph);
1076        let graph2 = parse_document(&output).expect("re-parse of ellipse failed");
1077        let node = graph2.get_by_id(NodeId::intern("dot")).unwrap();
1078        match &node.kind {
1079            NodeKind::Ellipse { rx, ry } => {
1080                assert_eq!(*rx, 40.0);
1081                assert_eq!(*ry, 40.0);
1082            }
1083            _ => panic!("expected Ellipse"),
1084        }
1085    }
1086
1087    #[test]
1088    fn roundtrip_text_with_font() {
1089        let input = r#"
1090text @title "Hello" {
1091  font: "Inter" 700 32
1092  fill: #1A1A2E
1093}
1094"#;
1095        let graph = parse_document(input).unwrap();
1096        let output = emit_document(&graph);
1097        let graph2 = parse_document(&output).expect("re-parse of text failed");
1098        let node = graph2.get_by_id(NodeId::intern("title")).unwrap();
1099        match &node.kind {
1100            NodeKind::Text { content } => assert_eq!(content, "Hello"),
1101            _ => panic!("expected Text"),
1102        }
1103        let font = node.style.font.as_ref().expect("font missing");
1104        assert_eq!(font.family, "Inter");
1105        assert_eq!(font.weight, 700);
1106        assert_eq!(font.size, 32.0);
1107    }
1108
1109    #[test]
1110    fn roundtrip_nested_group() {
1111        let input = r#"
1112group @card {
1113  layout: column gap=16 pad=24
1114
1115  text @heading "Title" {
1116    font: "Inter" 600 20
1117    fill: #333333
1118  }
1119
1120  rect @body {
1121    w: 300 h: 200
1122    fill: #F5F5F5
1123  }
1124}
1125"#;
1126        let graph = parse_document(input).unwrap();
1127        let output = emit_document(&graph);
1128        let graph2 = parse_document(&output).expect("re-parse of nested group failed");
1129        let card_idx = graph2.index_of(NodeId::intern("card")).unwrap();
1130        assert_eq!(graph2.children(card_idx).len(), 2);
1131    }
1132
1133    #[test]
1134    fn roundtrip_animation() {
1135        let input = r#"
1136rect @btn {
1137  w: 200 h: 48
1138  fill: #6C5CE7
1139
1140  anim :hover {
1141    fill: #5A4BD1
1142    scale: 1.02
1143    ease: spring 300ms
1144  }
1145}
1146"#;
1147        let graph = parse_document(input).unwrap();
1148        let output = emit_document(&graph);
1149        let graph2 = parse_document(&output).expect("re-parse of animation failed");
1150        let btn = graph2.get_by_id(NodeId::intern("btn")).unwrap();
1151        assert_eq!(btn.animations.len(), 1);
1152        assert_eq!(btn.animations[0].trigger, AnimTrigger::Hover);
1153    }
1154
1155    #[test]
1156    fn roundtrip_style_and_use() {
1157        let input = r#"
1158style accent {
1159  fill: #6C5CE7
1160  corner: 10
1161}
1162
1163rect @btn {
1164  w: 200 h: 48
1165  use: accent
1166}
1167"#;
1168        let graph = parse_document(input).unwrap();
1169        let output = emit_document(&graph);
1170        let graph2 = parse_document(&output).expect("re-parse of style+use failed");
1171        assert!(graph2.styles.contains_key(&NodeId::intern("accent")));
1172        let btn = graph2.get_by_id(NodeId::intern("btn")).unwrap();
1173        assert_eq!(btn.use_styles.len(), 1);
1174    }
1175
1176    #[test]
1177    fn roundtrip_annotation_description() {
1178        let input = r#"
1179rect @box {
1180  spec "Primary container for content"
1181  w: 100 h: 50
1182  fill: #FF0000
1183}
1184"#;
1185        let graph = parse_document(input).unwrap();
1186        let node = graph.get_by_id(NodeId::intern("box")).unwrap();
1187        assert_eq!(node.annotations.len(), 1);
1188        assert_eq!(
1189            node.annotations[0],
1190            Annotation::Description("Primary container for content".into())
1191        );
1192
1193        let output = emit_document(&graph);
1194        let graph2 = parse_document(&output).expect("re-parse of annotation failed");
1195        let node2 = graph2.get_by_id(NodeId::intern("box")).unwrap();
1196        assert_eq!(node2.annotations.len(), 1);
1197        assert_eq!(node2.annotations[0], node.annotations[0]);
1198    }
1199
1200    #[test]
1201    fn roundtrip_annotation_accept() {
1202        let input = r#"
1203rect @login_btn {
1204  spec {
1205    accept: "disabled state when fields empty"
1206    accept: "loading spinner during auth"
1207  }
1208  w: 280 h: 48
1209  fill: #6C5CE7
1210}
1211"#;
1212        let graph = parse_document(input).unwrap();
1213        let btn = graph.get_by_id(NodeId::intern("login_btn")).unwrap();
1214        assert_eq!(btn.annotations.len(), 2);
1215        assert_eq!(
1216            btn.annotations[0],
1217            Annotation::Accept("disabled state when fields empty".into())
1218        );
1219        assert_eq!(
1220            btn.annotations[1],
1221            Annotation::Accept("loading spinner during auth".into())
1222        );
1223
1224        let output = emit_document(&graph);
1225        let graph2 = parse_document(&output).expect("re-parse of accept annotation failed");
1226        let btn2 = graph2.get_by_id(NodeId::intern("login_btn")).unwrap();
1227        assert_eq!(btn2.annotations, btn.annotations);
1228    }
1229
1230    #[test]
1231    fn roundtrip_annotation_status_priority() {
1232        let input = r#"
1233rect @card {
1234  spec {
1235    status: doing
1236    priority: high
1237    tag: mvp
1238  }
1239  w: 300 h: 200
1240}
1241"#;
1242        let graph = parse_document(input).unwrap();
1243        let card = graph.get_by_id(NodeId::intern("card")).unwrap();
1244        assert_eq!(card.annotations.len(), 3);
1245        assert_eq!(card.annotations[0], Annotation::Status("doing".into()));
1246        assert_eq!(card.annotations[1], Annotation::Priority("high".into()));
1247        assert_eq!(card.annotations[2], Annotation::Tag("mvp".into()));
1248
1249        let output = emit_document(&graph);
1250        let graph2 =
1251            parse_document(&output).expect("re-parse of status/priority/tag annotation failed");
1252        let card2 = graph2.get_by_id(NodeId::intern("card")).unwrap();
1253        assert_eq!(card2.annotations, card.annotations);
1254    }
1255
1256    #[test]
1257    fn roundtrip_annotation_nested() {
1258        let input = r#"
1259group @form {
1260  layout: column gap=16 pad=32
1261  spec "User authentication entry point"
1262
1263  rect @email {
1264    spec {
1265      accept: "validates email format"
1266    }
1267    w: 280 h: 44
1268  }
1269}
1270"#;
1271        let graph = parse_document(input).unwrap();
1272        let form = graph.get_by_id(NodeId::intern("form")).unwrap();
1273        assert_eq!(form.annotations.len(), 1);
1274        let email = graph.get_by_id(NodeId::intern("email")).unwrap();
1275        assert_eq!(email.annotations.len(), 1);
1276
1277        let output = emit_document(&graph);
1278        let graph2 = parse_document(&output).expect("re-parse of nested annotation failed");
1279        let form2 = graph2.get_by_id(NodeId::intern("form")).unwrap();
1280        assert_eq!(form2.annotations, form.annotations);
1281        let email2 = graph2.get_by_id(NodeId::intern("email")).unwrap();
1282        assert_eq!(email2.annotations, email.annotations);
1283    }
1284
1285    #[test]
1286    fn parse_annotation_freeform() {
1287        let input = r#"
1288rect @widget {
1289  spec {
1290    "Description line"
1291    accept: "criterion one"
1292    status: done
1293    priority: low
1294    tag: design
1295  }
1296  w: 100 h: 100
1297}
1298"#;
1299        let graph = parse_document(input).unwrap();
1300        let w = graph.get_by_id(NodeId::intern("widget")).unwrap();
1301        assert_eq!(w.annotations.len(), 5);
1302        assert_eq!(
1303            w.annotations[0],
1304            Annotation::Description("Description line".into())
1305        );
1306        assert_eq!(w.annotations[1], Annotation::Accept("criterion one".into()));
1307        assert_eq!(w.annotations[2], Annotation::Status("done".into()));
1308        assert_eq!(w.annotations[3], Annotation::Priority("low".into()));
1309        assert_eq!(w.annotations[4], Annotation::Tag("design".into()));
1310    }
1311
1312    #[test]
1313    fn roundtrip_edge_basic() {
1314        let input = r#"
1315rect @box_a {
1316  w: 100 h: 50
1317}
1318
1319rect @box_b {
1320  w: 100 h: 50
1321}
1322
1323edge @a_to_b {
1324  from: @box_a
1325  to: @box_b
1326  label: "next step"
1327  arrow: end
1328}
1329"#;
1330        let graph = parse_document(input).unwrap();
1331        assert_eq!(graph.edges.len(), 1);
1332        let edge = &graph.edges[0];
1333        assert_eq!(edge.id.as_str(), "a_to_b");
1334        assert_eq!(edge.from.as_str(), "box_a");
1335        assert_eq!(edge.to.as_str(), "box_b");
1336        assert_eq!(edge.label.as_deref(), Some("next step"));
1337        assert_eq!(edge.arrow, ArrowKind::End);
1338
1339        // Re-parse roundtrip
1340        let output = emit_document(&graph);
1341        let graph2 = parse_document(&output).expect("roundtrip failed");
1342        assert_eq!(graph2.edges.len(), 1);
1343        let edge2 = &graph2.edges[0];
1344        assert_eq!(edge2.from.as_str(), "box_a");
1345        assert_eq!(edge2.to.as_str(), "box_b");
1346        assert_eq!(edge2.label.as_deref(), Some("next step"));
1347        assert_eq!(edge2.arrow, ArrowKind::End);
1348    }
1349
1350    #[test]
1351    fn roundtrip_edge_styled() {
1352        let input = r#"
1353rect @s1 { w: 50 h: 50 }
1354rect @s2 { w: 50 h: 50 }
1355
1356edge @flow {
1357  from: @s1
1358  to: @s2
1359  stroke: #6C5CE7 2
1360  arrow: both
1361  curve: smooth
1362}
1363"#;
1364        let graph = parse_document(input).unwrap();
1365        assert_eq!(graph.edges.len(), 1);
1366        let edge = &graph.edges[0];
1367        assert_eq!(edge.arrow, ArrowKind::Both);
1368        assert_eq!(edge.curve, CurveKind::Smooth);
1369        assert!(edge.style.stroke.is_some());
1370
1371        let output = emit_document(&graph);
1372        let graph2 = parse_document(&output).expect("styled edge roundtrip failed");
1373        let edge2 = &graph2.edges[0];
1374        assert_eq!(edge2.arrow, ArrowKind::Both);
1375        assert_eq!(edge2.curve, CurveKind::Smooth);
1376    }
1377
1378    #[test]
1379    fn roundtrip_edge_with_annotations() {
1380        let input = r#"
1381rect @login { w: 200 h: 100 }
1382rect @dashboard { w: 200 h: 100 }
1383
1384edge @login_flow {
1385  spec {
1386    "Main authentication flow"
1387    accept: "must redirect within 2s"
1388  }
1389  from: @login
1390  to: @dashboard
1391  label: "on success"
1392  arrow: end
1393}
1394"#;
1395        let graph = parse_document(input).unwrap();
1396        let edge = &graph.edges[0];
1397        assert_eq!(edge.annotations.len(), 2);
1398        assert_eq!(
1399            edge.annotations[0],
1400            Annotation::Description("Main authentication flow".into())
1401        );
1402        assert_eq!(
1403            edge.annotations[1],
1404            Annotation::Accept("must redirect within 2s".into())
1405        );
1406
1407        let output = emit_document(&graph);
1408        let graph2 = parse_document(&output).expect("annotated edge roundtrip failed");
1409        let edge2 = &graph2.edges[0];
1410        assert_eq!(edge2.annotations, edge.annotations);
1411    }
1412
1413    #[test]
1414    fn roundtrip_generic_node() {
1415        let input = r#"
1416@login_btn {
1417  spec {
1418    "Primary CTA — triggers login API call"
1419    accept: "disabled when fields empty"
1420    status: doing
1421  }
1422}
1423"#;
1424        let graph = parse_document(input).unwrap();
1425        let node = graph.get_by_id(NodeId::intern("login_btn")).unwrap();
1426        assert!(matches!(node.kind, NodeKind::Generic));
1427        assert_eq!(node.annotations.len(), 3);
1428
1429        let output = emit_document(&graph);
1430        assert!(output.contains("@login_btn {"));
1431        // Should NOT have a type prefix
1432        assert!(!output.contains("rect @login_btn"));
1433        assert!(!output.contains("group @login_btn"));
1434
1435        let graph2 = parse_document(&output).expect("re-parse of generic node failed");
1436        let node2 = graph2.get_by_id(NodeId::intern("login_btn")).unwrap();
1437        assert!(matches!(node2.kind, NodeKind::Generic));
1438        assert_eq!(node2.annotations, node.annotations);
1439    }
1440
1441    #[test]
1442    fn roundtrip_generic_nested() {
1443        let input = r#"
1444group @form {
1445  layout: column gap=16 pad=32
1446
1447  @email_input {
1448    spec {
1449      "Email field"
1450      accept: "validates format on blur"
1451    }
1452  }
1453
1454  @password_input {
1455    spec {
1456      "Password field"
1457      accept: "min 8 chars"
1458    }
1459  }
1460}
1461"#;
1462        let graph = parse_document(input).unwrap();
1463        let form_idx = graph.index_of(NodeId::intern("form")).unwrap();
1464        assert_eq!(graph.children(form_idx).len(), 2);
1465
1466        let email = graph.get_by_id(NodeId::intern("email_input")).unwrap();
1467        assert!(matches!(email.kind, NodeKind::Generic));
1468        assert_eq!(email.annotations.len(), 2);
1469
1470        let output = emit_document(&graph);
1471        let graph2 = parse_document(&output).expect("re-parse of nested generic failed");
1472        let email2 = graph2.get_by_id(NodeId::intern("email_input")).unwrap();
1473        assert!(matches!(email2.kind, NodeKind::Generic));
1474        assert_eq!(email2.annotations, email.annotations);
1475    }
1476
1477    #[test]
1478    fn parse_generic_with_properties() {
1479        let input = r#"
1480@card {
1481  fill: #FFFFFF
1482  corner: 8
1483}
1484"#;
1485        let graph = parse_document(input).unwrap();
1486        let card = graph.get_by_id(NodeId::intern("card")).unwrap();
1487        assert!(matches!(card.kind, NodeKind::Generic));
1488        assert!(card.style.fill.is_some());
1489        assert_eq!(card.style.corner_radius, Some(8.0));
1490    }
1491
1492    #[test]
1493    fn roundtrip_edge_with_trigger_anim() {
1494        let input = r#"
1495rect @a { w: 50 h: 50 }
1496rect @b { w: 50 h: 50 }
1497
1498edge @hover_edge {
1499  from: @a
1500  to: @b
1501  stroke: #6C5CE7 2
1502  arrow: end
1503
1504  anim :hover {
1505    opacity: 0.5
1506    ease: ease_out 200ms
1507  }
1508}
1509"#;
1510        let graph = parse_document(input).unwrap();
1511        assert_eq!(graph.edges.len(), 1);
1512        let edge = &graph.edges[0];
1513        assert_eq!(edge.animations.len(), 1);
1514        assert_eq!(edge.animations[0].trigger, AnimTrigger::Hover);
1515        assert_eq!(edge.animations[0].duration_ms, 200);
1516
1517        let output = emit_document(&graph);
1518        let graph2 = parse_document(&output).expect("trigger anim roundtrip failed");
1519        let edge2 = &graph2.edges[0];
1520        assert_eq!(edge2.animations.len(), 1);
1521        assert_eq!(edge2.animations[0].trigger, AnimTrigger::Hover);
1522    }
1523
1524    #[test]
1525    fn roundtrip_edge_with_flow() {
1526        let input = r#"
1527rect @src { w: 50 h: 50 }
1528rect @dst { w: 50 h: 50 }
1529
1530edge @data {
1531  from: @src
1532  to: @dst
1533  arrow: end
1534  flow: pulse 800ms
1535}
1536"#;
1537        let graph = parse_document(input).unwrap();
1538        let edge = &graph.edges[0];
1539        assert!(edge.flow.is_some());
1540        let flow = edge.flow.unwrap();
1541        assert_eq!(flow.kind, FlowKind::Pulse);
1542        assert_eq!(flow.duration_ms, 800);
1543
1544        let output = emit_document(&graph);
1545        let graph2 = parse_document(&output).expect("flow roundtrip failed");
1546        let edge2 = &graph2.edges[0];
1547        let flow2 = edge2.flow.unwrap();
1548        assert_eq!(flow2.kind, FlowKind::Pulse);
1549        assert_eq!(flow2.duration_ms, 800);
1550    }
1551
1552    #[test]
1553    fn roundtrip_edge_dash_flow() {
1554        let input = r#"
1555rect @x { w: 50 h: 50 }
1556rect @y { w: 50 h: 50 }
1557
1558edge @dashed {
1559  from: @x
1560  to: @y
1561  stroke: #EF4444 1
1562  flow: dash 400ms
1563  arrow: both
1564  curve: step
1565}
1566"#;
1567        let graph = parse_document(input).unwrap();
1568        let edge = &graph.edges[0];
1569        let flow = edge.flow.unwrap();
1570        assert_eq!(flow.kind, FlowKind::Dash);
1571        assert_eq!(flow.duration_ms, 400);
1572        assert_eq!(edge.arrow, ArrowKind::Both);
1573        assert_eq!(edge.curve, CurveKind::Step);
1574
1575        let output = emit_document(&graph);
1576        let graph2 = parse_document(&output).expect("dash flow roundtrip failed");
1577        let edge2 = &graph2.edges[0];
1578        let flow2 = edge2.flow.unwrap();
1579        assert_eq!(flow2.kind, FlowKind::Dash);
1580        assert_eq!(flow2.duration_ms, 400);
1581    }
1582
1583    #[test]
1584    fn test_spec_markdown_basic() {
1585        let input = r#"
1586rect @login_btn {
1587  spec {
1588    "Primary CTA for login"
1589    accept: "disabled when fields empty"
1590    status: doing
1591    priority: high
1592    tag: auth
1593  }
1594  w: 280 h: 48
1595  fill: #6C5CE7
1596}
1597"#;
1598        let graph = parse_document(input).unwrap();
1599        let md = emit_spec_markdown(&graph, "login.fd");
1600
1601        assert!(md.starts_with("# Spec: login.fd\n"));
1602        assert!(md.contains("## @login_btn `rect`"));
1603        assert!(md.contains("> Primary CTA for login"));
1604        assert!(md.contains("- [ ] disabled when fields empty"));
1605        assert!(md.contains("- **Status:** doing"));
1606        assert!(md.contains("- **Priority:** high"));
1607        assert!(md.contains("- **Tag:** auth"));
1608        // Visual props must NOT appear
1609        assert!(!md.contains("280"));
1610        assert!(!md.contains("6C5CE7"));
1611    }
1612
1613    #[test]
1614    fn test_spec_markdown_nested() {
1615        let input = r#"
1616group @form {
1617  layout: column gap=16 pad=32
1618  spec {
1619    "Shipping address form"
1620    accept: "autofill from saved addresses"
1621  }
1622
1623  rect @email {
1624    spec {
1625      "Email input"
1626      accept: "validates email format"
1627    }
1628    w: 280 h: 44
1629  }
1630
1631  rect @no_annotations {
1632    w: 100 h: 50
1633    fill: #CCC
1634  }
1635}
1636"#;
1637        let graph = parse_document(input).unwrap();
1638        let md = emit_spec_markdown(&graph, "checkout.fd");
1639
1640        assert!(md.contains("## @form `group`"));
1641        assert!(md.contains("### @email `rect`"));
1642        assert!(md.contains("> Shipping address form"));
1643        assert!(md.contains("- [ ] autofill from saved addresses"));
1644        assert!(md.contains("- [ ] validates email format"));
1645        // Node without annotations should be skipped
1646        assert!(!md.contains("no_annotations"));
1647    }
1648
1649    #[test]
1650    fn test_spec_markdown_with_edges() {
1651        let input = r#"
1652rect @login { w: 200 h: 100 }
1653rect @dashboard {
1654  spec "Main dashboard"
1655  w: 200 h: 100
1656}
1657
1658edge @auth_flow {
1659  spec {
1660    "Authentication flow"
1661    accept: "redirect within 2s"
1662  }
1663  from: @login
1664  to: @dashboard
1665  label: "on success"
1666  arrow: end
1667}
1668"#;
1669        let graph = parse_document(input).unwrap();
1670        let md = emit_spec_markdown(&graph, "flow.fd");
1671
1672        assert!(md.contains("## Flows"));
1673        assert!(md.contains("**@login** → **@dashboard**"));
1674        assert!(md.contains("on success"));
1675        assert!(md.contains("> Authentication flow"));
1676        assert!(md.contains("- [ ] redirect within 2s"));
1677    }
1678
1679    #[test]
1680    fn roundtrip_import_basic() {
1681        let input = "import \"components/buttons.fd\" as btn\nrect @hero { w: 200 h: 100 }\n";
1682        let graph = parse_document(input).unwrap();
1683        assert_eq!(graph.imports.len(), 1);
1684        assert_eq!(graph.imports[0].path, "components/buttons.fd");
1685        assert_eq!(graph.imports[0].namespace, "btn");
1686
1687        let output = emit_document(&graph);
1688        assert!(output.contains("import \"components/buttons.fd\" as btn"));
1689
1690        let graph2 = parse_document(&output).expect("re-parse of import failed");
1691        assert_eq!(graph2.imports.len(), 1);
1692        assert_eq!(graph2.imports[0].path, "components/buttons.fd");
1693        assert_eq!(graph2.imports[0].namespace, "btn");
1694    }
1695
1696    #[test]
1697    fn roundtrip_import_multiple() {
1698        let input = "import \"tokens.fd\" as tokens\nimport \"buttons.fd\" as btn\nrect @box { w: 50 h: 50 }\n";
1699        let graph = parse_document(input).unwrap();
1700        assert_eq!(graph.imports.len(), 2);
1701        assert_eq!(graph.imports[0].namespace, "tokens");
1702        assert_eq!(graph.imports[1].namespace, "btn");
1703
1704        let output = emit_document(&graph);
1705        let graph2 = parse_document(&output).expect("re-parse of multiple imports failed");
1706        assert_eq!(graph2.imports.len(), 2);
1707        assert_eq!(graph2.imports[0].namespace, "tokens");
1708        assert_eq!(graph2.imports[1].namespace, "btn");
1709    }
1710
1711    #[test]
1712    fn parse_import_without_alias_errors() {
1713        let input = "import \"missing_alias.fd\"\nrect @box { w: 50 h: 50 }\n";
1714        // This should fail because "as namespace" is missing
1715        let result = parse_document(input);
1716        assert!(result.is_err());
1717    }
1718
1719    #[test]
1720    fn roundtrip_comment_preserved() {
1721        // A `# comment` before a node should survive parse → emit → parse.
1722        let input = r#"
1723# This is a section header
1724rect @box {
1725  w: 100 h: 50
1726  fill: #FF0000
1727}
1728"#;
1729        let graph = parse_document(input).unwrap();
1730        let output = emit_document(&graph);
1731        assert!(
1732            output.contains("# This is a section header"),
1733            "comment should appear in emitted output: {output}"
1734        );
1735        // Re-parse should also preserve it
1736        let graph2 = parse_document(&output).expect("re-parse of commented document failed");
1737        let node = graph2.get_by_id(NodeId::intern("box")).unwrap();
1738        assert_eq!(node.comments, vec!["This is a section header"]);
1739    }
1740
1741    #[test]
1742    fn roundtrip_multiple_comments_preserved() {
1743        let input = r#"
1744# Header section
1745# Subheading
1746rect @panel {
1747  w: 300 h: 200
1748}
1749"#;
1750        let graph = parse_document(input).unwrap();
1751        let output = emit_document(&graph);
1752        let graph2 = parse_document(&output).expect("re-parse failed");
1753        let node = graph2.get_by_id(NodeId::intern("panel")).unwrap();
1754        assert_eq!(node.comments.len(), 2);
1755        assert_eq!(node.comments[0], "Header section");
1756        assert_eq!(node.comments[1], "Subheading");
1757    }
1758
1759    #[test]
1760    fn roundtrip_inline_position() {
1761        let input = r#"
1762rect @placed {
1763  x: 100
1764  y: 200
1765  w: 50 h: 50
1766  fill: #FF0000
1767}
1768"#;
1769        let graph = parse_document(input).unwrap();
1770        let node = graph.get_by_id(NodeId::intern("placed")).unwrap();
1771
1772        // Should have a Position constraint from x:/y: parsing
1773        assert!(
1774            node.constraints
1775                .iter()
1776                .any(|c| matches!(c, Constraint::Position { .. })),
1777            "should have Position constraint"
1778        );
1779
1780        // Emit and verify x:/y: appear inline (not as top-level arrow)
1781        let output = emit_document(&graph);
1782        assert!(output.contains("x: 100"), "should emit x: inline");
1783        assert!(output.contains("y: 200"), "should emit y: inline");
1784        assert!(
1785            !output.contains("-> absolute"),
1786            "should NOT emit old absolute arrow"
1787        );
1788        assert!(
1789            !output.contains("-> position"),
1790            "should NOT emit position arrow"
1791        );
1792
1793        // Round-trip: re-parse emitted output
1794        let graph2 = parse_document(&output).expect("re-parse of inline position failed");
1795        let node2 = graph2.get_by_id(NodeId::intern("placed")).unwrap();
1796        let pos = node2
1797            .constraints
1798            .iter()
1799            .find_map(|c| match c {
1800                Constraint::Position { x, y } => Some((*x, *y)),
1801                _ => None,
1802            })
1803            .expect("Position constraint missing after roundtrip");
1804        assert_eq!(pos, (100.0, 200.0));
1805    }
1806
1807    #[test]
1808    fn emit_children_before_styles() {
1809        let input = r#"
1810rect @box {
1811  w: 200 h: 100
1812  fill: #FF0000
1813  corner: 10
1814  text @label "Hello" {
1815    fill: #FFFFFF
1816    font: "Inter" 600 14
1817  }
1818  anim :hover {
1819    fill: #CC0000
1820    ease: ease_out 200ms
1821  }
1822}
1823"#;
1824        let graph = parse_document(input).unwrap();
1825        let output = emit_document(&graph);
1826
1827        // Children should appear before inline appearance properties
1828        let child_pos = output.find("text @label").expect("child missing");
1829        let fill_pos = output.find("fill: #FF0000").expect("fill missing");
1830        let corner_pos = output.find("corner: 10").expect("corner missing");
1831        let anim_pos = output.find("when :hover").expect("when missing");
1832
1833        assert!(
1834            child_pos < fill_pos,
1835            "children should appear before fill: child_pos={child_pos} fill_pos={fill_pos}"
1836        );
1837        assert!(
1838            child_pos < corner_pos,
1839            "children should appear before corner"
1840        );
1841        assert!(fill_pos < anim_pos, "fill should appear before animations");
1842    }
1843
1844    #[test]
1845    fn emit_section_separators() {
1846        let input = r#"
1847style accent {
1848  fill: #6C5CE7
1849}
1850
1851rect @a {
1852  w: 100 h: 50
1853}
1854
1855rect @b {
1856  w: 100 h: 50
1857}
1858
1859edge @flow {
1860  from: @a
1861  to: @b
1862  arrow: end
1863}
1864
1865@a -> center_in: canvas
1866"#;
1867        let graph = parse_document(input).unwrap();
1868        let output = emit_document(&graph);
1869
1870        assert!(
1871            output.contains("# ─── Themes ───"),
1872            "should have Themes separator"
1873        );
1874        assert!(
1875            output.contains("# ─── Layout ───"),
1876            "should have Layout separator"
1877        );
1878        assert!(
1879            output.contains("# ─── Flows ───"),
1880            "should have Flows separator"
1881        );
1882    }
1883
1884    #[test]
1885    fn roundtrip_children_before_styles() {
1886        let input = r#"
1887group @card {
1888  layout: column gap=12 pad=20
1889  text @title "Dashboard" {
1890    font: "Inter" 600 20
1891    fill: #111111
1892  }
1893  rect @body {
1894    w: 300 h: 200
1895    fill: #F5F5F5
1896  }
1897  fill: #FFFFFF
1898  corner: 8
1899  shadow: (0,2,8,#00000011)
1900}
1901"#;
1902        let graph = parse_document(input).unwrap();
1903        let output = emit_document(&graph);
1904
1905        // Re-parse the re-ordered output
1906        let graph2 = parse_document(&output).expect("re-parse of reordered output failed");
1907        let card_idx = graph2.index_of(NodeId::intern("card")).unwrap();
1908        assert_eq!(
1909            graph2.children(card_idx).len(),
1910            2,
1911            "card should still have 2 children after roundtrip"
1912        );
1913
1914        // Verify children appear before appearance
1915        let child_pos = output.find("text @title").expect("child missing");
1916        let fill_pos = output.find("fill: #FFFFFF").expect("card fill missing");
1917        assert!(
1918            child_pos < fill_pos,
1919            "children should appear before parent fill"
1920        );
1921    }
1922
1923    #[test]
1924    fn roundtrip_theme_keyword() {
1925        // Verify that `theme` keyword parses and emits correctly
1926        let input = r#"
1927theme accent {
1928  fill: #6C5CE7
1929  corner: 12
1930}
1931
1932rect @btn {
1933  w: 120 h: 40
1934  use: accent
1935}
1936"#;
1937        let graph = parse_document(input).unwrap();
1938        let output = emit_document(&graph);
1939
1940        // Emitter should output `theme`, not `style`
1941        assert!(
1942            output.contains("theme accent"),
1943            "should emit `theme` keyword"
1944        );
1945        assert!(
1946            !output.contains("style accent"),
1947            "should NOT emit `style` keyword"
1948        );
1949
1950        // Round-trip: re-parse emitted output
1951        let graph2 = parse_document(&output).expect("re-parse of theme output failed");
1952        assert!(
1953            graph2.styles.contains_key(&NodeId::intern("accent")),
1954            "theme definition should survive roundtrip"
1955        );
1956    }
1957
1958    #[test]
1959    fn roundtrip_when_keyword() {
1960        // Verify that `when` keyword parses and emits correctly
1961        let input = r#"
1962rect @btn {
1963  w: 120 h: 40
1964  fill: #6C5CE7
1965  when :hover {
1966    fill: #5A4BD1
1967    ease: ease_out 200ms
1968  }
1969}
1970"#;
1971        let graph = parse_document(input).unwrap();
1972        let output = emit_document(&graph);
1973
1974        // Emitter should output `when`, not `anim`
1975        assert!(output.contains("when :hover"), "should emit `when` keyword");
1976        assert!(
1977            !output.contains("anim :hover"),
1978            "should NOT emit `anim` keyword"
1979        );
1980
1981        // Round-trip: re-parse emitted output
1982        let graph2 = parse_document(&output).expect("re-parse of when output failed");
1983        let node = graph2.get_by_id(NodeId::intern("btn")).unwrap();
1984        assert_eq!(
1985            node.animations.len(),
1986            1,
1987            "animation should survive roundtrip"
1988        );
1989        assert_eq!(
1990            node.animations[0].trigger,
1991            AnimTrigger::Hover,
1992            "trigger should be Hover"
1993        );
1994    }
1995
1996    #[test]
1997    fn parse_old_style_keyword_compat() {
1998        // Old `style` keyword must still be accepted by the parser
1999        let input = r#"
2000style accent {
2001  fill: #6C5CE7
2002}
2003
2004rect @btn {
2005  w: 120 h: 40
2006  use: accent
2007}
2008"#;
2009        let graph = parse_document(input).unwrap();
2010        assert!(
2011            graph.styles.contains_key(&NodeId::intern("accent")),
2012            "old `style` keyword should parse into a theme definition"
2013        );
2014
2015        // Emitter should upgrade to `theme`
2016        let output = emit_document(&graph);
2017        assert!(
2018            output.contains("theme accent"),
2019            "emitter should upgrade `style` to `theme`"
2020        );
2021    }
2022
2023    #[test]
2024    fn parse_old_anim_keyword_compat() {
2025        // Old `anim` keyword must still be accepted by the parser
2026        let input = r#"
2027rect @btn {
2028  w: 120 h: 40
2029  fill: #6C5CE7
2030  anim :press {
2031    scale: 0.95
2032    ease: spring 150ms
2033  }
2034}
2035"#;
2036        let graph = parse_document(input).unwrap();
2037        let node = graph.get_by_id(NodeId::intern("btn")).unwrap();
2038        assert_eq!(
2039            node.animations.len(),
2040            1,
2041            "old `anim` keyword should parse into animation"
2042        );
2043        assert_eq!(
2044            node.animations[0].trigger,
2045            AnimTrigger::Press,
2046            "trigger should be Press"
2047        );
2048
2049        // Emitter should upgrade to `when`
2050        let output = emit_document(&graph);
2051        assert!(
2052            output.contains("when :press"),
2053            "emitter should upgrade `anim` to `when`"
2054        );
2055    }
2056
2057    #[test]
2058    fn roundtrip_theme_import() {
2059        // Verify that import + theme references work together
2060        let input = r#"
2061import "tokens.fd" as tokens
2062
2063theme card_base {
2064  fill: #FFFFFF
2065  corner: 16
2066}
2067
2068rect @card {
2069  w: 300 h: 200
2070  use: card_base
2071}
2072"#;
2073        let graph = parse_document(input).unwrap();
2074        let output = emit_document(&graph);
2075
2076        // Both import and theme should appear in output
2077        assert!(
2078            output.contains("import \"tokens.fd\" as tokens"),
2079            "import should survive roundtrip"
2080        );
2081        assert!(
2082            output.contains("theme card_base"),
2083            "theme should survive roundtrip"
2084        );
2085
2086        // Re-parse
2087        let graph2 = parse_document(&output).expect("re-parse failed");
2088        assert_eq!(graph2.imports.len(), 1, "import count should survive");
2089        assert!(
2090            graph2.styles.contains_key(&NodeId::intern("card_base")),
2091            "theme def should survive"
2092        );
2093    }
2094
2095    // ─── Hardening: edge-case round-trip tests ─────────────────────────────
2096
2097    #[test]
2098    fn roundtrip_empty_group() {
2099        let input = "group @empty {\n}\n";
2100        let graph = parse_document(input).unwrap();
2101        let output = emit_document(&graph);
2102        let graph2 = parse_document(&output).expect("re-parse of empty group failed");
2103        let node = graph2.get_by_id(NodeId::intern("empty")).unwrap();
2104        assert!(matches!(node.kind, NodeKind::Group { .. }));
2105    }
2106
2107    #[test]
2108    fn roundtrip_deeply_nested_groups() {
2109        let input = r#"
2110group @outer {
2111  group @middle {
2112    group @inner {
2113      rect @leaf {
2114        w: 40 h: 20
2115        fill: #FF0000
2116      }
2117    }
2118  }
2119}
2120"#;
2121        let graph = parse_document(input).unwrap();
2122        let output = emit_document(&graph);
2123        let graph2 = parse_document(&output).expect("re-parse of nested groups failed");
2124        let leaf = graph2.get_by_id(NodeId::intern("leaf")).unwrap();
2125        assert!(matches!(leaf.kind, NodeKind::Rect { .. }));
2126        // Verify 3-level nesting preserved
2127        let inner_idx = graph2.index_of(NodeId::intern("inner")).unwrap();
2128        assert_eq!(graph2.children(inner_idx).len(), 1);
2129        let middle_idx = graph2.index_of(NodeId::intern("middle")).unwrap();
2130        assert_eq!(graph2.children(middle_idx).len(), 1);
2131    }
2132
2133    #[test]
2134    fn roundtrip_unicode_text() {
2135        let input = "text @emoji \"Hello 🎨 café 日本語\" {\n  fill: #333333\n}\n";
2136        let graph = parse_document(input).unwrap();
2137        let output = emit_document(&graph);
2138        assert!(
2139            output.contains("Hello 🎨 café 日本語"),
2140            "unicode should survive emit"
2141        );
2142        let graph2 = parse_document(&output).expect("re-parse of unicode failed");
2143        let node = graph2.get_by_id(NodeId::intern("emoji")).unwrap();
2144        match &node.kind {
2145            NodeKind::Text { content } => {
2146                assert!(content.contains("🎨"));
2147                assert!(content.contains("café"));
2148                assert!(content.contains("日本語"));
2149            }
2150            _ => panic!("expected Text node"),
2151        }
2152    }
2153
2154    #[test]
2155    fn roundtrip_spec_all_fields() {
2156        let input = r#"
2157rect @full_spec {
2158  spec {
2159    "Full specification node"
2160    accept: "all fields present"
2161    status: doing
2162    priority: high
2163    tag: mvp, auth
2164  }
2165  w: 100 h: 50
2166}
2167"#;
2168        let graph = parse_document(input).unwrap();
2169        let node = graph.get_by_id(NodeId::intern("full_spec")).unwrap();
2170        assert_eq!(node.annotations.len(), 5, "should have 5 annotations");
2171
2172        let output = emit_document(&graph);
2173        let graph2 = parse_document(&output).expect("re-parse of full spec failed");
2174        let node2 = graph2.get_by_id(NodeId::intern("full_spec")).unwrap();
2175        assert_eq!(node2.annotations.len(), 5);
2176        assert_eq!(node2.annotations, node.annotations);
2177    }
2178
2179    #[test]
2180    fn roundtrip_path_node() {
2181        let input = "path @sketch {\n}\n";
2182        let graph = parse_document(input).unwrap();
2183        let output = emit_document(&graph);
2184        let graph2 = parse_document(&output).expect("re-parse of path failed");
2185        let node = graph2.get_by_id(NodeId::intern("sketch")).unwrap();
2186        assert!(matches!(node.kind, NodeKind::Path { .. }));
2187    }
2188
2189    #[test]
2190    fn roundtrip_gradient_linear() {
2191        let input = r#"
2192rect @grad {
2193  w: 200 h: 100
2194  fill: linear(90deg, #FF0000 0, #0000FF 1)
2195}
2196"#;
2197        let graph = parse_document(input).unwrap();
2198        let node = graph.get_by_id(NodeId::intern("grad")).unwrap();
2199        assert!(matches!(
2200            node.style.fill,
2201            Some(Paint::LinearGradient { .. })
2202        ));
2203
2204        let output = emit_document(&graph);
2205        assert!(output.contains("linear("), "should emit linear gradient");
2206        let graph2 = parse_document(&output).expect("re-parse of linear gradient failed");
2207        let node2 = graph2.get_by_id(NodeId::intern("grad")).unwrap();
2208        assert!(matches!(
2209            node2.style.fill,
2210            Some(Paint::LinearGradient { .. })
2211        ));
2212    }
2213
2214    #[test]
2215    fn roundtrip_gradient_radial() {
2216        let input = r#"
2217rect @radial_box {
2218  w: 100 h: 100
2219  fill: radial(#FFFFFF 0, #000000 1)
2220}
2221"#;
2222        let graph = parse_document(input).unwrap();
2223        let node = graph.get_by_id(NodeId::intern("radial_box")).unwrap();
2224        assert!(matches!(
2225            node.style.fill,
2226            Some(Paint::RadialGradient { .. })
2227        ));
2228
2229        let output = emit_document(&graph);
2230        assert!(output.contains("radial("), "should emit radial gradient");
2231        let graph2 = parse_document(&output).expect("re-parse of radial gradient failed");
2232        let node2 = graph2.get_by_id(NodeId::intern("radial_box")).unwrap();
2233        assert!(matches!(
2234            node2.style.fill,
2235            Some(Paint::RadialGradient { .. })
2236        ));
2237    }
2238
2239    #[test]
2240    fn roundtrip_shadow_property() {
2241        let input = r#"
2242rect @shadowed {
2243  w: 200 h: 100
2244  shadow: (0,4,20,#000000)
2245}
2246"#;
2247        let graph = parse_document(input).unwrap();
2248        let node = graph.get_by_id(NodeId::intern("shadowed")).unwrap();
2249        let shadow = node.style.shadow.as_ref().expect("shadow should exist");
2250        assert_eq!(shadow.blur, 20.0);
2251
2252        let output = emit_document(&graph);
2253        assert!(output.contains("shadow:"), "should emit shadow");
2254        let graph2 = parse_document(&output).expect("re-parse of shadow failed");
2255        let node2 = graph2.get_by_id(NodeId::intern("shadowed")).unwrap();
2256        let shadow2 = node2.style.shadow.as_ref().expect("shadow should survive");
2257        assert_eq!(shadow2.offset_y, 4.0);
2258        assert_eq!(shadow2.blur, 20.0);
2259    }
2260
2261    #[test]
2262    fn roundtrip_opacity() {
2263        let input = r#"
2264rect @faded {
2265  w: 100 h: 100
2266  fill: #6C5CE7
2267  opacity: 0.5
2268}
2269"#;
2270        let graph = parse_document(input).unwrap();
2271        let node = graph.get_by_id(NodeId::intern("faded")).unwrap();
2272        assert_eq!(node.style.opacity, Some(0.5));
2273
2274        let output = emit_document(&graph);
2275        let graph2 = parse_document(&output).expect("re-parse of opacity failed");
2276        let node2 = graph2.get_by_id(NodeId::intern("faded")).unwrap();
2277        assert_eq!(node2.style.opacity, Some(0.5));
2278    }
2279
2280    #[test]
2281    fn roundtrip_clip_frame() {
2282        let input = r#"
2283frame @clipped {
2284  w: 300 h: 200
2285  clip: true
2286  fill: #FFFFFF
2287  corner: 12
2288}
2289"#;
2290        let graph = parse_document(input).unwrap();
2291        let output = emit_document(&graph);
2292        assert!(output.contains("clip: true"), "should emit clip");
2293        let graph2 = parse_document(&output).expect("re-parse of clip frame failed");
2294        let node = graph2.get_by_id(NodeId::intern("clipped")).unwrap();
2295        match &node.kind {
2296            NodeKind::Frame { clip, .. } => assert!(clip, "clip should be true"),
2297            _ => panic!("expected Frame node"),
2298        }
2299    }
2300
2301    #[test]
2302    fn roundtrip_multiple_animations() {
2303        let input = r#"
2304rect @animated {
2305  w: 120 h: 40
2306  fill: #6C5CE7
2307  when :hover {
2308    fill: #5A4BD1
2309    scale: 1.05
2310    ease: ease_out 200ms
2311  }
2312  when :press {
2313    scale: 0.95
2314    ease: spring 150ms
2315  }
2316}
2317"#;
2318        let graph = parse_document(input).unwrap();
2319        let node = graph.get_by_id(NodeId::intern("animated")).unwrap();
2320        assert_eq!(node.animations.len(), 2, "should have 2 animations");
2321
2322        let output = emit_document(&graph);
2323        let graph2 = parse_document(&output).expect("re-parse of multi-anim failed");
2324        let node2 = graph2.get_by_id(NodeId::intern("animated")).unwrap();
2325        assert_eq!(node2.animations.len(), 2);
2326        assert_eq!(node2.animations[0].trigger, AnimTrigger::Hover);
2327        assert_eq!(node2.animations[1].trigger, AnimTrigger::Press);
2328    }
2329
2330    #[test]
2331    fn roundtrip_inline_spec_shorthand() {
2332        let input = r#"
2333rect @btn {
2334  spec "Primary action button"
2335  w: 180 h: 48
2336  fill: #6C5CE7
2337}
2338"#;
2339        let graph = parse_document(input).unwrap();
2340        let node = graph.get_by_id(NodeId::intern("btn")).unwrap();
2341        assert_eq!(node.annotations.len(), 1);
2342        assert!(matches!(
2343            &node.annotations[0],
2344            Annotation::Description(d) if d == "Primary action button"
2345        ));
2346
2347        let output = emit_document(&graph);
2348        let graph2 = parse_document(&output).expect("re-parse of inline spec failed");
2349        let node2 = graph2.get_by_id(NodeId::intern("btn")).unwrap();
2350        assert_eq!(node2.annotations, node.annotations);
2351    }
2352
2353    #[test]
2354    fn roundtrip_layout_modes() {
2355        let input = r#"
2356group @col {
2357  layout: column gap=16 pad=24
2358  rect @c1 { w: 100 h: 50 }
2359}
2360
2361group @rw {
2362  layout: row gap=8 pad=12
2363  rect @r1 { w: 50 h: 50 }
2364}
2365
2366group @grd {
2367  layout: grid cols=2 gap=10 pad=20
2368  rect @g1 { w: 80 h: 80 }
2369}
2370"#;
2371        let graph = parse_document(input).unwrap();
2372        let output = emit_document(&graph);
2373        assert!(output.contains("layout: column gap=16 pad=24"));
2374        assert!(output.contains("layout: row gap=8 pad=12"));
2375        assert!(output.contains("layout: grid cols=2 gap=10 pad=20"));
2376
2377        let graph2 = parse_document(&output).expect("re-parse of layout modes failed");
2378        let col = graph2.get_by_id(NodeId::intern("col")).unwrap();
2379        assert!(matches!(
2380            col.kind,
2381            NodeKind::Group {
2382                layout: LayoutMode::Column { .. }
2383            }
2384        ));
2385        let rw = graph2.get_by_id(NodeId::intern("rw")).unwrap();
2386        assert!(matches!(
2387            rw.kind,
2388            NodeKind::Group {
2389                layout: LayoutMode::Row { .. }
2390            }
2391        ));
2392        let grd = graph2.get_by_id(NodeId::intern("grd")).unwrap();
2393        assert!(matches!(
2394            grd.kind,
2395            NodeKind::Group {
2396                layout: LayoutMode::Grid { .. }
2397            }
2398        ));
2399    }
2400
2401    // ─── emit_filtered tests ─────────────────────────────────────────────
2402
2403    fn make_test_graph() -> SceneGraph {
2404        // A rich document with styles, layout, anims, specs, and edges
2405        let input = r#"
2406theme accent {
2407  fill: #6C5CE7
2408  font: "Inter" bold 16
2409}
2410
2411group @container {
2412  layout: column gap=16 pad=24
2413
2414  rect @card {
2415    w: 200 h: 100
2416    use: accent
2417    fill: #FFFFFF
2418    corner: 12
2419    spec {
2420      "Main card component"
2421      status: done
2422    }
2423    when :hover {
2424      fill: #F0EDFF
2425      scale: 1.05
2426      ease: ease_out 200ms
2427    }
2428  }
2429
2430  text @label "Hello" {
2431    font: "Inter" regular 14
2432    fill: #333333
2433    x: 20
2434    y: 40
2435  }
2436}
2437
2438edge @card_to_label {
2439  from: @card
2440  to: @label
2441  label: "displays"
2442}
2443"#;
2444        parse_document(input).unwrap()
2445    }
2446
2447    #[test]
2448    fn emit_filtered_full_matches_emit_document() {
2449        let graph = make_test_graph();
2450        let full = emit_filtered(&graph, ReadMode::Full);
2451        let doc = emit_document(&graph);
2452        assert_eq!(full, doc, "Full mode should be identical to emit_document");
2453    }
2454
2455    #[test]
2456    fn emit_filtered_structure() {
2457        let graph = make_test_graph();
2458        let out = emit_filtered(&graph, ReadMode::Structure);
2459        // Should have node declarations
2460        assert!(out.contains("group @container"), "should include group");
2461        assert!(out.contains("rect @card"), "should include rect");
2462        assert!(out.contains("text @label"), "should include text");
2463        // Should NOT have styles, dimensions, specs, or anims
2464        assert!(!out.contains("fill:"), "no fill in structure mode");
2465        assert!(!out.contains("w:"), "no dimensions in structure mode");
2466        assert!(!out.contains("spec"), "no spec in structure mode");
2467        assert!(!out.contains("when"), "no when in structure mode");
2468        assert!(!out.contains("theme"), "no theme in structure mode");
2469        assert!(!out.contains("edge"), "no edges in structure mode");
2470    }
2471
2472    #[test]
2473    fn emit_filtered_layout() {
2474        let graph = make_test_graph();
2475        let out = emit_filtered(&graph, ReadMode::Layout);
2476        // Should have layout + dimensions
2477        assert!(out.contains("layout: column"), "should include layout");
2478        assert!(out.contains("w: 200 h: 100"), "should include dims");
2479        assert!(out.contains("x: 20"), "should include position");
2480        // Should NOT have styles or anims
2481        assert!(!out.contains("fill:"), "no fill in layout mode");
2482        assert!(!out.contains("theme"), "no theme in layout mode");
2483        assert!(!out.contains("when :hover"), "no when in layout mode");
2484    }
2485
2486    #[test]
2487    fn emit_filtered_design() {
2488        let graph = make_test_graph();
2489        let out = emit_filtered(&graph, ReadMode::Design);
2490        // Should have themes + styles
2491        assert!(out.contains("theme accent"), "should include theme");
2492        assert!(out.contains("use: accent"), "should include use ref");
2493        assert!(out.contains("fill:"), "should include fill");
2494        assert!(out.contains("corner: 12"), "should include corner");
2495        // Should NOT have layout or anims
2496        assert!(!out.contains("layout:"), "no layout in design mode");
2497        assert!(!out.contains("w: 200"), "no dims in design mode");
2498        assert!(!out.contains("when :hover"), "no when in design mode");
2499    }
2500
2501    #[test]
2502    fn emit_filtered_spec() {
2503        let graph = make_test_graph();
2504        let out = emit_filtered(&graph, ReadMode::Spec);
2505        // Should have spec blocks
2506        assert!(out.contains("spec"), "should include spec");
2507        assert!(out.contains("Main card component"), "should include desc");
2508        assert!(out.contains("status: done"), "should include status");
2509        // Should NOT have styles or anims
2510        assert!(!out.contains("fill:"), "no fill in spec mode");
2511        assert!(!out.contains("when"), "no when in spec mode");
2512    }
2513
2514    #[test]
2515    fn emit_filtered_visual() {
2516        let graph = make_test_graph();
2517        let out = emit_filtered(&graph, ReadMode::Visual);
2518        // Visual = Layout + Design + When
2519        assert!(out.contains("theme accent"), "should include theme");
2520        assert!(out.contains("layout: column"), "should include layout");
2521        assert!(out.contains("w: 200 h: 100"), "should include dims");
2522        assert!(out.contains("fill:"), "should include fill");
2523        assert!(out.contains("corner: 12"), "should include corner");
2524        assert!(out.contains("when :hover"), "should include when");
2525        assert!(out.contains("scale: 1.05"), "should include anim props");
2526        assert!(out.contains("edge @card_to_label"), "should include edges");
2527        // Should NOT have spec blocks
2528        assert!(
2529            !out.contains("Main card component"),
2530            "no spec desc in visual mode"
2531        );
2532    }
2533
2534    #[test]
2535    fn emit_filtered_when() {
2536        let graph = make_test_graph();
2537        let out = emit_filtered(&graph, ReadMode::When);
2538        // Should have when blocks
2539        assert!(out.contains("when :hover"), "should include when");
2540        assert!(out.contains("scale: 1.05"), "should include anim props");
2541        // Should NOT have node-level styles, layout, or spec
2542        assert!(!out.contains("corner:"), "no corner in when mode");
2543        assert!(!out.contains("w: 200"), "no dims in when mode");
2544        assert!(!out.contains("theme"), "no theme in when mode");
2545        assert!(!out.contains("spec"), "no spec in when mode");
2546    }
2547
2548    #[test]
2549    fn emit_filtered_edges() {
2550        let graph = make_test_graph();
2551        let out = emit_filtered(&graph, ReadMode::Edges);
2552        // Should have edges
2553        assert!(out.contains("edge @card_to_label"), "should include edge");
2554        assert!(out.contains("from: @card"), "should include from");
2555        assert!(out.contains("to: @label"), "should include to");
2556        assert!(out.contains("label: \"displays\""), "should include label");
2557        // Should NOT have styles or anims
2558        assert!(!out.contains("fill:"), "no fill in edges mode");
2559        assert!(!out.contains("when"), "no when in edges mode");
2560    }
2561}