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