Skip to main content

fd_core/
emitter.rs

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