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    // Label offset (dragged position)
662    if let Some((ox, oy)) = edge.label_offset {
663        writeln!(out, "  label_offset: {} {}", format_num(ox), format_num(oy)).unwrap();
664    }
665
666    // Trigger animations
667    for anim in &edge.animations {
668        emit_anim(out, anim, 1);
669    }
670
671    out.push_str("}\n");
672}
673
674// ─── Read Modes (filtered emit for AI agents) ────────────────────────────
675
676/// What an AI agent wants to read from the document.
677///
678/// Each mode selectively emits only the properties relevant to a specific
679/// concern, saving 50-80% tokens while preserving structural understanding.
680#[derive(Debug, Clone, Copy, PartialEq, Eq)]
681pub enum ReadMode {
682    /// Full file — no filtering (identical to `emit_document`).
683    Full,
684    /// Node types, `@id`s, parent-child nesting only.
685    Structure,
686    /// Structure + dimensions (`w:`/`h:`) + `layout:` directives + constraints.
687    Layout,
688    /// Structure + themes/styles + `fill:`/`stroke:`/`font:`/`corner:`/`use:` refs.
689    Design,
690    /// Structure + `spec {}` blocks + annotations.
691    Spec,
692    /// Layout + Design + When combined — the full visual story.
693    Visual,
694    /// Structure + `when :trigger { ... }` animation blocks only.
695    When,
696    /// Structure + `edge @id { ... }` blocks.
697    Edges,
698}
699
700/// Emit a `SceneGraph` filtered to show only the properties relevant to `mode`.
701///
702/// - `Full`: identical to `emit_document`.
703/// - `Structure`: node kind + `@id` + children. No styles, dims, anims, specs.
704/// - `Layout`: structure + `w:`/`h:` + `layout:` + constraints (`->`).
705/// - `Design`: structure + themes + `fill:`/`stroke:`/`font:`/`corner:`/`use:`.
706/// - `Spec`: structure + `spec {}` blocks.
707/// - `Visual`: layout + design + when combined.
708/// - `When`: structure + `when :trigger { ... }` blocks.
709/// - `Edges`: structure + `edge @id { ... }` blocks.
710#[must_use]
711pub fn emit_filtered(graph: &SceneGraph, mode: ReadMode) -> String {
712    if mode == ReadMode::Full {
713        return emit_document(graph);
714    }
715
716    let mut out = String::with_capacity(1024);
717
718    let children = graph.children(graph.root);
719    let include_themes = matches!(mode, ReadMode::Design | ReadMode::Visual);
720    let include_constraints = matches!(mode, ReadMode::Layout | ReadMode::Visual);
721    let include_edges = matches!(mode, ReadMode::Edges | ReadMode::Visual);
722
723    // Themes (Design and Visual modes)
724    if include_themes && !graph.styles.is_empty() {
725        let mut styles: Vec<_> = graph.styles.iter().collect();
726        styles.sort_by_key(|(id, _)| id.as_str().to_string());
727        for (name, style) in &styles {
728            emit_style_block(&mut out, name, style, 0);
729            out.push('\n');
730        }
731    }
732
733    // Node tree (always emitted, but with per-mode filtering)
734    for child_idx in &children {
735        emit_node_filtered(&mut out, graph, *child_idx, 0, mode);
736        out.push('\n');
737    }
738
739    // Constraints (Layout and Visual modes)
740    if include_constraints {
741        for idx in graph.graph.node_indices() {
742            let node = &graph.graph[idx];
743            for constraint in &node.constraints {
744                if matches!(constraint, Constraint::Position { .. }) {
745                    continue;
746                }
747                emit_constraint(&mut out, &node.id, constraint);
748            }
749        }
750    }
751
752    // Edges (Edges and Visual modes)
753    if include_edges {
754        for edge in &graph.edges {
755            emit_edge(&mut out, edge);
756            out.push('\n');
757        }
758    }
759
760    out
761}
762
763/// Emit a single node with mode-based property filtering.
764fn emit_node_filtered(
765    out: &mut String,
766    graph: &SceneGraph,
767    idx: NodeIndex,
768    depth: usize,
769    mode: ReadMode,
770) {
771    let node = &graph.graph[idx];
772
773    if matches!(node.kind, NodeKind::Root) {
774        return;
775    }
776
777    indent(out, depth);
778
779    // Node kind + @id (always emitted)
780    match &node.kind {
781        NodeKind::Root => return,
782        NodeKind::Generic => write!(out, "@{}", node.id.as_str()).unwrap(),
783        NodeKind::Group { .. } => write!(out, "group @{}", node.id.as_str()).unwrap(),
784        NodeKind::Frame { .. } => write!(out, "frame @{}", node.id.as_str()).unwrap(),
785        NodeKind::Rect { .. } => write!(out, "rect @{}", node.id.as_str()).unwrap(),
786        NodeKind::Ellipse { .. } => write!(out, "ellipse @{}", node.id.as_str()).unwrap(),
787        NodeKind::Path { .. } => write!(out, "path @{}", node.id.as_str()).unwrap(),
788        NodeKind::Text { content } => {
789            write!(out, "text @{} \"{}\"", node.id.as_str(), content).unwrap();
790        }
791    }
792
793    out.push_str(" {\n");
794
795    // Spec annotations (Spec mode only)
796    if mode == ReadMode::Spec {
797        emit_annotations(out, &node.annotations, depth + 1);
798    }
799
800    // Children (always recurse)
801    let children = graph.children(idx);
802    for child_idx in &children {
803        emit_node_filtered(out, graph, *child_idx, depth + 1, mode);
804    }
805
806    // Layout directives (Layout and Visual modes)
807    if matches!(mode, ReadMode::Layout | ReadMode::Visual) {
808        emit_layout_mode_filtered(out, &node.kind, depth + 1);
809    }
810
811    // Dimensions (Layout and Visual modes)
812    if matches!(mode, ReadMode::Layout | ReadMode::Visual) {
813        emit_dimensions_filtered(out, &node.kind, depth + 1);
814    }
815
816    // Style properties (Design and Visual modes)
817    if matches!(mode, ReadMode::Design | ReadMode::Visual) {
818        for style_ref in &node.use_styles {
819            indent(out, depth + 1);
820            writeln!(out, "use: {}", style_ref.as_str()).unwrap();
821        }
822        if let Some(ref fill) = node.style.fill {
823            emit_paint_prop(out, "fill", fill, depth + 1);
824        }
825        if let Some(ref stroke) = node.style.stroke {
826            indent(out, depth + 1);
827            match &stroke.paint {
828                Paint::Solid(c) => {
829                    writeln!(out, "stroke: {} {}", c.to_hex(), format_num(stroke.width)).unwrap();
830                }
831                _ => writeln!(out, "stroke: #000 {}", format_num(stroke.width)).unwrap(),
832            }
833        }
834        if let Some(radius) = node.style.corner_radius {
835            indent(out, depth + 1);
836            writeln!(out, "corner: {}", format_num(radius)).unwrap();
837        }
838        if let Some(ref font) = node.style.font {
839            emit_font_prop(out, font, depth + 1);
840        }
841        if let Some(opacity) = node.style.opacity {
842            indent(out, depth + 1);
843            writeln!(out, "opacity: {}", format_num(opacity)).unwrap();
844        }
845    }
846
847    // Inline position (Layout and Visual modes)
848    if matches!(mode, ReadMode::Layout | ReadMode::Visual) {
849        for constraint in &node.constraints {
850            if let Constraint::Position { x, y } = constraint {
851                if *x != 0.0 {
852                    indent(out, depth + 1);
853                    writeln!(out, "x: {}", format_num(*x)).unwrap();
854                }
855                if *y != 0.0 {
856                    indent(out, depth + 1);
857                    writeln!(out, "y: {}", format_num(*y)).unwrap();
858                }
859            }
860        }
861    }
862
863    // Animations / when blocks (When and Visual modes)
864    if matches!(mode, ReadMode::When | ReadMode::Visual) {
865        for anim in &node.animations {
866            emit_anim(out, anim, depth + 1);
867        }
868    }
869
870    indent(out, depth);
871    out.push_str("}\n");
872}
873
874/// Emit layout mode directive for groups and frames (filtered path).
875fn emit_layout_mode_filtered(out: &mut String, kind: &NodeKind, depth: usize) {
876    let layout = match kind {
877        NodeKind::Group { layout } | NodeKind::Frame { layout, .. } => layout,
878        _ => return,
879    };
880    match layout {
881        LayoutMode::Free => {}
882        LayoutMode::Column { gap, pad } => {
883            indent(out, depth);
884            writeln!(
885                out,
886                "layout: column gap={} pad={}",
887                format_num(*gap),
888                format_num(*pad)
889            )
890            .unwrap();
891        }
892        LayoutMode::Row { gap, pad } => {
893            indent(out, depth);
894            writeln!(
895                out,
896                "layout: row gap={} pad={}",
897                format_num(*gap),
898                format_num(*pad)
899            )
900            .unwrap();
901        }
902        LayoutMode::Grid { cols, gap, pad } => {
903            indent(out, depth);
904            writeln!(
905                out,
906                "layout: grid cols={cols} gap={} pad={}",
907                format_num(*gap),
908                format_num(*pad)
909            )
910            .unwrap();
911        }
912    }
913}
914
915/// Emit dimension properties (w/h) for sized nodes (filtered path).
916fn emit_dimensions_filtered(out: &mut String, kind: &NodeKind, depth: usize) {
917    match kind {
918        NodeKind::Rect { width, height } | NodeKind::Frame { width, height, .. } => {
919            indent(out, depth);
920            writeln!(out, "w: {} h: {}", format_num(*width), format_num(*height)).unwrap();
921        }
922        NodeKind::Ellipse { rx, ry } => {
923            indent(out, depth);
924            writeln!(out, "w: {} h: {}", format_num(*rx), format_num(*ry)).unwrap();
925        }
926        _ => {}
927    }
928}
929
930// ─── Spec Markdown Export ─────────────────────────────────────────────────
931
932/// Emit a `SceneGraph` as a markdown spec document.
933///
934/// Extracts only `@id` names, `spec { ... }` annotations, hierarchy, and edges —
935/// all visual properties (fill, stroke, dimensions, animations) are omitted.
936/// Intended for PM-facing spec reports.
937#[must_use]
938pub fn emit_spec_markdown(graph: &SceneGraph, title: &str) -> String {
939    let mut out = String::with_capacity(512);
940    writeln!(out, "# Spec: {title}\n").unwrap();
941
942    // Emit root's children
943    let children = graph.children(graph.root);
944    for child_idx in &children {
945        emit_spec_node(&mut out, graph, *child_idx, 2);
946    }
947
948    // Emit edges as flow descriptions
949    if !graph.edges.is_empty() {
950        out.push_str("\n---\n\n## Flows\n\n");
951        for edge in &graph.edges {
952            write!(
953                out,
954                "- **@{}** → **@{}**",
955                edge.from.as_str(),
956                edge.to.as_str()
957            )
958            .unwrap();
959            if let Some(ref label) = edge.label {
960                write!(out, " — {label}").unwrap();
961            }
962            out.push('\n');
963            emit_spec_annotations(&mut out, &edge.annotations, "  ");
964        }
965    }
966
967    out
968}
969
970fn emit_spec_node(out: &mut String, graph: &SceneGraph, idx: NodeIndex, heading_level: usize) {
971    let node = &graph.graph[idx];
972
973    // Skip nodes with no annotations and no annotated children
974    let has_annotations = !node.annotations.is_empty();
975    let children = graph.children(idx);
976    let has_annotated_children = children
977        .iter()
978        .any(|c| has_annotations_recursive(graph, *c));
979
980    if !has_annotations && !has_annotated_children {
981        return;
982    }
983
984    // Heading: ## @node_id (kind)
985    let hashes = "#".repeat(heading_level.min(6));
986    let kind_label = match &node.kind {
987        NodeKind::Root => return,
988        NodeKind::Generic => "spec",
989        NodeKind::Group { .. } => "group",
990        NodeKind::Frame { .. } => "frame",
991        NodeKind::Rect { .. } => "rect",
992        NodeKind::Ellipse { .. } => "ellipse",
993        NodeKind::Path { .. } => "path",
994        NodeKind::Text { .. } => "text",
995    };
996    writeln!(out, "{hashes} @{} `{kind_label}`\n", node.id.as_str()).unwrap();
997
998    // Annotation details
999    emit_spec_annotations(out, &node.annotations, "");
1000
1001    // Children (recurse with deeper heading level)
1002    for child_idx in &children {
1003        emit_spec_node(out, graph, *child_idx, heading_level + 1);
1004    }
1005}
1006
1007fn has_annotations_recursive(graph: &SceneGraph, idx: NodeIndex) -> bool {
1008    let node = &graph.graph[idx];
1009    if !node.annotations.is_empty() {
1010        return true;
1011    }
1012    graph
1013        .children(idx)
1014        .iter()
1015        .any(|c| has_annotations_recursive(graph, *c))
1016}
1017
1018fn emit_spec_annotations(out: &mut String, annotations: &[Annotation], prefix: &str) {
1019    for ann in annotations {
1020        match ann {
1021            Annotation::Description(s) => writeln!(out, "{prefix}> {s}").unwrap(),
1022            Annotation::Accept(s) => writeln!(out, "{prefix}- [ ] {s}").unwrap(),
1023            Annotation::Status(s) => writeln!(out, "{prefix}- **Status:** {s}").unwrap(),
1024            Annotation::Priority(s) => writeln!(out, "{prefix}- **Priority:** {s}").unwrap(),
1025            Annotation::Tag(s) => writeln!(out, "{prefix}- **Tag:** {s}").unwrap(),
1026        }
1027    }
1028    if !annotations.is_empty() {
1029        out.push('\n');
1030    }
1031}
1032
1033/// Format a float without trailing zeros for compact output.
1034fn format_num(n: f32) -> String {
1035    if n == n.floor() {
1036        format!("{}", n as i32)
1037    } else {
1038        format!("{n:.2}")
1039            .trim_end_matches('0')
1040            .trim_end_matches('.')
1041            .to_string()
1042    }
1043}
1044
1045#[cfg(test)]
1046mod tests {
1047    use super::*;
1048    use crate::parser::parse_document;
1049
1050    #[test]
1051    fn roundtrip_simple() {
1052        let input = r#"
1053rect @box {
1054  w: 100
1055  h: 50
1056  fill: #FF0000
1057}
1058"#;
1059        let graph = parse_document(input).unwrap();
1060        let output = emit_document(&graph);
1061
1062        // Re-parse the emitted output
1063        let graph2 = parse_document(&output).expect("re-parse of emitted output failed");
1064        let node2 = graph2.get_by_id(NodeId::intern("box")).unwrap();
1065
1066        match &node2.kind {
1067            NodeKind::Rect { width, height } => {
1068                assert_eq!(*width, 100.0);
1069                assert_eq!(*height, 50.0);
1070            }
1071            _ => panic!("expected Rect"),
1072        }
1073    }
1074
1075    #[test]
1076    fn roundtrip_ellipse() {
1077        let input = r#"
1078ellipse @dot {
1079  w: 40 h: 40
1080  fill: #00FF00
1081}
1082"#;
1083        let graph = parse_document(input).unwrap();
1084        let output = emit_document(&graph);
1085        let graph2 = parse_document(&output).expect("re-parse of ellipse failed");
1086        let node = graph2.get_by_id(NodeId::intern("dot")).unwrap();
1087        match &node.kind {
1088            NodeKind::Ellipse { rx, ry } => {
1089                assert_eq!(*rx, 40.0);
1090                assert_eq!(*ry, 40.0);
1091            }
1092            _ => panic!("expected Ellipse"),
1093        }
1094    }
1095
1096    #[test]
1097    fn roundtrip_text_with_font() {
1098        let input = r#"
1099text @title "Hello" {
1100  font: "Inter" 700 32
1101  fill: #1A1A2E
1102}
1103"#;
1104        let graph = parse_document(input).unwrap();
1105        let output = emit_document(&graph);
1106        let graph2 = parse_document(&output).expect("re-parse of text failed");
1107        let node = graph2.get_by_id(NodeId::intern("title")).unwrap();
1108        match &node.kind {
1109            NodeKind::Text { content } => assert_eq!(content, "Hello"),
1110            _ => panic!("expected Text"),
1111        }
1112        let font = node.style.font.as_ref().expect("font missing");
1113        assert_eq!(font.family, "Inter");
1114        assert_eq!(font.weight, 700);
1115        assert_eq!(font.size, 32.0);
1116    }
1117
1118    #[test]
1119    fn roundtrip_nested_group() {
1120        let input = r#"
1121group @card {
1122  layout: column gap=16 pad=24
1123
1124  text @heading "Title" {
1125    font: "Inter" 600 20
1126    fill: #333333
1127  }
1128
1129  rect @body {
1130    w: 300 h: 200
1131    fill: #F5F5F5
1132  }
1133}
1134"#;
1135        let graph = parse_document(input).unwrap();
1136        let output = emit_document(&graph);
1137        let graph2 = parse_document(&output).expect("re-parse of nested group failed");
1138        let card_idx = graph2.index_of(NodeId::intern("card")).unwrap();
1139        assert_eq!(graph2.children(card_idx).len(), 2);
1140    }
1141
1142    #[test]
1143    fn roundtrip_animation() {
1144        let input = r#"
1145rect @btn {
1146  w: 200 h: 48
1147  fill: #6C5CE7
1148
1149  anim :hover {
1150    fill: #5A4BD1
1151    scale: 1.02
1152    ease: spring 300ms
1153  }
1154}
1155"#;
1156        let graph = parse_document(input).unwrap();
1157        let output = emit_document(&graph);
1158        let graph2 = parse_document(&output).expect("re-parse of animation failed");
1159        let btn = graph2.get_by_id(NodeId::intern("btn")).unwrap();
1160        assert_eq!(btn.animations.len(), 1);
1161        assert_eq!(btn.animations[0].trigger, AnimTrigger::Hover);
1162    }
1163
1164    #[test]
1165    fn roundtrip_style_and_use() {
1166        let input = r#"
1167style accent {
1168  fill: #6C5CE7
1169  corner: 10
1170}
1171
1172rect @btn {
1173  w: 200 h: 48
1174  use: accent
1175}
1176"#;
1177        let graph = parse_document(input).unwrap();
1178        let output = emit_document(&graph);
1179        let graph2 = parse_document(&output).expect("re-parse of style+use failed");
1180        assert!(graph2.styles.contains_key(&NodeId::intern("accent")));
1181        let btn = graph2.get_by_id(NodeId::intern("btn")).unwrap();
1182        assert_eq!(btn.use_styles.len(), 1);
1183    }
1184
1185    #[test]
1186    fn roundtrip_annotation_description() {
1187        let input = r#"
1188rect @box {
1189  spec "Primary container for content"
1190  w: 100 h: 50
1191  fill: #FF0000
1192}
1193"#;
1194        let graph = parse_document(input).unwrap();
1195        let node = graph.get_by_id(NodeId::intern("box")).unwrap();
1196        assert_eq!(node.annotations.len(), 1);
1197        assert_eq!(
1198            node.annotations[0],
1199            Annotation::Description("Primary container for content".into())
1200        );
1201
1202        let output = emit_document(&graph);
1203        let graph2 = parse_document(&output).expect("re-parse of annotation failed");
1204        let node2 = graph2.get_by_id(NodeId::intern("box")).unwrap();
1205        assert_eq!(node2.annotations.len(), 1);
1206        assert_eq!(node2.annotations[0], node.annotations[0]);
1207    }
1208
1209    #[test]
1210    fn roundtrip_annotation_accept() {
1211        let input = r#"
1212rect @login_btn {
1213  spec {
1214    accept: "disabled state when fields empty"
1215    accept: "loading spinner during auth"
1216  }
1217  w: 280 h: 48
1218  fill: #6C5CE7
1219}
1220"#;
1221        let graph = parse_document(input).unwrap();
1222        let btn = graph.get_by_id(NodeId::intern("login_btn")).unwrap();
1223        assert_eq!(btn.annotations.len(), 2);
1224        assert_eq!(
1225            btn.annotations[0],
1226            Annotation::Accept("disabled state when fields empty".into())
1227        );
1228        assert_eq!(
1229            btn.annotations[1],
1230            Annotation::Accept("loading spinner during auth".into())
1231        );
1232
1233        let output = emit_document(&graph);
1234        let graph2 = parse_document(&output).expect("re-parse of accept annotation failed");
1235        let btn2 = graph2.get_by_id(NodeId::intern("login_btn")).unwrap();
1236        assert_eq!(btn2.annotations, btn.annotations);
1237    }
1238
1239    #[test]
1240    fn roundtrip_annotation_status_priority() {
1241        let input = r#"
1242rect @card {
1243  spec {
1244    status: doing
1245    priority: high
1246    tag: mvp
1247  }
1248  w: 300 h: 200
1249}
1250"#;
1251        let graph = parse_document(input).unwrap();
1252        let card = graph.get_by_id(NodeId::intern("card")).unwrap();
1253        assert_eq!(card.annotations.len(), 3);
1254        assert_eq!(card.annotations[0], Annotation::Status("doing".into()));
1255        assert_eq!(card.annotations[1], Annotation::Priority("high".into()));
1256        assert_eq!(card.annotations[2], Annotation::Tag("mvp".into()));
1257
1258        let output = emit_document(&graph);
1259        let graph2 =
1260            parse_document(&output).expect("re-parse of status/priority/tag annotation failed");
1261        let card2 = graph2.get_by_id(NodeId::intern("card")).unwrap();
1262        assert_eq!(card2.annotations, card.annotations);
1263    }
1264
1265    #[test]
1266    fn roundtrip_annotation_nested() {
1267        let input = r#"
1268group @form {
1269  layout: column gap=16 pad=32
1270  spec "User authentication entry point"
1271
1272  rect @email {
1273    spec {
1274      accept: "validates email format"
1275    }
1276    w: 280 h: 44
1277  }
1278}
1279"#;
1280        let graph = parse_document(input).unwrap();
1281        let form = graph.get_by_id(NodeId::intern("form")).unwrap();
1282        assert_eq!(form.annotations.len(), 1);
1283        let email = graph.get_by_id(NodeId::intern("email")).unwrap();
1284        assert_eq!(email.annotations.len(), 1);
1285
1286        let output = emit_document(&graph);
1287        let graph2 = parse_document(&output).expect("re-parse of nested annotation failed");
1288        let form2 = graph2.get_by_id(NodeId::intern("form")).unwrap();
1289        assert_eq!(form2.annotations, form.annotations);
1290        let email2 = graph2.get_by_id(NodeId::intern("email")).unwrap();
1291        assert_eq!(email2.annotations, email.annotations);
1292    }
1293
1294    #[test]
1295    fn parse_annotation_freeform() {
1296        let input = r#"
1297rect @widget {
1298  spec {
1299    "Description line"
1300    accept: "criterion one"
1301    status: done
1302    priority: low
1303    tag: design
1304  }
1305  w: 100 h: 100
1306}
1307"#;
1308        let graph = parse_document(input).unwrap();
1309        let w = graph.get_by_id(NodeId::intern("widget")).unwrap();
1310        assert_eq!(w.annotations.len(), 5);
1311        assert_eq!(
1312            w.annotations[0],
1313            Annotation::Description("Description line".into())
1314        );
1315        assert_eq!(w.annotations[1], Annotation::Accept("criterion one".into()));
1316        assert_eq!(w.annotations[2], Annotation::Status("done".into()));
1317        assert_eq!(w.annotations[3], Annotation::Priority("low".into()));
1318        assert_eq!(w.annotations[4], Annotation::Tag("design".into()));
1319    }
1320
1321    #[test]
1322    fn roundtrip_edge_basic() {
1323        let input = r#"
1324rect @box_a {
1325  w: 100 h: 50
1326}
1327
1328rect @box_b {
1329  w: 100 h: 50
1330}
1331
1332edge @a_to_b {
1333  from: @box_a
1334  to: @box_b
1335  label: "next step"
1336  arrow: end
1337}
1338"#;
1339        let graph = parse_document(input).unwrap();
1340        assert_eq!(graph.edges.len(), 1);
1341        let edge = &graph.edges[0];
1342        assert_eq!(edge.id.as_str(), "a_to_b");
1343        assert_eq!(edge.from.as_str(), "box_a");
1344        assert_eq!(edge.to.as_str(), "box_b");
1345        assert_eq!(edge.label.as_deref(), Some("next step"));
1346        assert_eq!(edge.arrow, ArrowKind::End);
1347
1348        // Re-parse roundtrip
1349        let output = emit_document(&graph);
1350        let graph2 = parse_document(&output).expect("roundtrip failed");
1351        assert_eq!(graph2.edges.len(), 1);
1352        let edge2 = &graph2.edges[0];
1353        assert_eq!(edge2.from.as_str(), "box_a");
1354        assert_eq!(edge2.to.as_str(), "box_b");
1355        assert_eq!(edge2.label.as_deref(), Some("next step"));
1356        assert_eq!(edge2.arrow, ArrowKind::End);
1357    }
1358
1359    #[test]
1360    fn roundtrip_edge_styled() {
1361        let input = r#"
1362rect @s1 { w: 50 h: 50 }
1363rect @s2 { w: 50 h: 50 }
1364
1365edge @flow {
1366  from: @s1
1367  to: @s2
1368  stroke: #6C5CE7 2
1369  arrow: both
1370  curve: smooth
1371}
1372"#;
1373        let graph = parse_document(input).unwrap();
1374        assert_eq!(graph.edges.len(), 1);
1375        let edge = &graph.edges[0];
1376        assert_eq!(edge.arrow, ArrowKind::Both);
1377        assert_eq!(edge.curve, CurveKind::Smooth);
1378        assert!(edge.style.stroke.is_some());
1379
1380        let output = emit_document(&graph);
1381        let graph2 = parse_document(&output).expect("styled edge roundtrip failed");
1382        let edge2 = &graph2.edges[0];
1383        assert_eq!(edge2.arrow, ArrowKind::Both);
1384        assert_eq!(edge2.curve, CurveKind::Smooth);
1385    }
1386
1387    #[test]
1388    fn roundtrip_edge_with_annotations() {
1389        let input = r#"
1390rect @login { w: 200 h: 100 }
1391rect @dashboard { w: 200 h: 100 }
1392
1393edge @login_flow {
1394  spec {
1395    "Main authentication flow"
1396    accept: "must redirect within 2s"
1397  }
1398  from: @login
1399  to: @dashboard
1400  label: "on success"
1401  arrow: end
1402}
1403"#;
1404        let graph = parse_document(input).unwrap();
1405        let edge = &graph.edges[0];
1406        assert_eq!(edge.annotations.len(), 2);
1407        assert_eq!(
1408            edge.annotations[0],
1409            Annotation::Description("Main authentication flow".into())
1410        );
1411        assert_eq!(
1412            edge.annotations[1],
1413            Annotation::Accept("must redirect within 2s".into())
1414        );
1415
1416        let output = emit_document(&graph);
1417        let graph2 = parse_document(&output).expect("annotated edge roundtrip failed");
1418        let edge2 = &graph2.edges[0];
1419        assert_eq!(edge2.annotations, edge.annotations);
1420    }
1421
1422    #[test]
1423    fn roundtrip_generic_node() {
1424        let input = r#"
1425@login_btn {
1426  spec {
1427    "Primary CTA — triggers login API call"
1428    accept: "disabled when fields empty"
1429    status: doing
1430  }
1431}
1432"#;
1433        let graph = parse_document(input).unwrap();
1434        let node = graph.get_by_id(NodeId::intern("login_btn")).unwrap();
1435        assert!(matches!(node.kind, NodeKind::Generic));
1436        assert_eq!(node.annotations.len(), 3);
1437
1438        let output = emit_document(&graph);
1439        assert!(output.contains("@login_btn {"));
1440        // Should NOT have a type prefix
1441        assert!(!output.contains("rect @login_btn"));
1442        assert!(!output.contains("group @login_btn"));
1443
1444        let graph2 = parse_document(&output).expect("re-parse of generic node failed");
1445        let node2 = graph2.get_by_id(NodeId::intern("login_btn")).unwrap();
1446        assert!(matches!(node2.kind, NodeKind::Generic));
1447        assert_eq!(node2.annotations, node.annotations);
1448    }
1449
1450    #[test]
1451    fn roundtrip_generic_nested() {
1452        let input = r#"
1453group @form {
1454  layout: column gap=16 pad=32
1455
1456  @email_input {
1457    spec {
1458      "Email field"
1459      accept: "validates format on blur"
1460    }
1461  }
1462
1463  @password_input {
1464    spec {
1465      "Password field"
1466      accept: "min 8 chars"
1467    }
1468  }
1469}
1470"#;
1471        let graph = parse_document(input).unwrap();
1472        let form_idx = graph.index_of(NodeId::intern("form")).unwrap();
1473        assert_eq!(graph.children(form_idx).len(), 2);
1474
1475        let email = graph.get_by_id(NodeId::intern("email_input")).unwrap();
1476        assert!(matches!(email.kind, NodeKind::Generic));
1477        assert_eq!(email.annotations.len(), 2);
1478
1479        let output = emit_document(&graph);
1480        let graph2 = parse_document(&output).expect("re-parse of nested generic failed");
1481        let email2 = graph2.get_by_id(NodeId::intern("email_input")).unwrap();
1482        assert!(matches!(email2.kind, NodeKind::Generic));
1483        assert_eq!(email2.annotations, email.annotations);
1484    }
1485
1486    #[test]
1487    fn parse_generic_with_properties() {
1488        let input = r#"
1489@card {
1490  fill: #FFFFFF
1491  corner: 8
1492}
1493"#;
1494        let graph = parse_document(input).unwrap();
1495        let card = graph.get_by_id(NodeId::intern("card")).unwrap();
1496        assert!(matches!(card.kind, NodeKind::Generic));
1497        assert!(card.style.fill.is_some());
1498        assert_eq!(card.style.corner_radius, Some(8.0));
1499    }
1500
1501    #[test]
1502    fn roundtrip_edge_with_trigger_anim() {
1503        let input = r#"
1504rect @a { w: 50 h: 50 }
1505rect @b { w: 50 h: 50 }
1506
1507edge @hover_edge {
1508  from: @a
1509  to: @b
1510  stroke: #6C5CE7 2
1511  arrow: end
1512
1513  anim :hover {
1514    opacity: 0.5
1515    ease: ease_out 200ms
1516  }
1517}
1518"#;
1519        let graph = parse_document(input).unwrap();
1520        assert_eq!(graph.edges.len(), 1);
1521        let edge = &graph.edges[0];
1522        assert_eq!(edge.animations.len(), 1);
1523        assert_eq!(edge.animations[0].trigger, AnimTrigger::Hover);
1524        assert_eq!(edge.animations[0].duration_ms, 200);
1525
1526        let output = emit_document(&graph);
1527        let graph2 = parse_document(&output).expect("trigger anim roundtrip failed");
1528        let edge2 = &graph2.edges[0];
1529        assert_eq!(edge2.animations.len(), 1);
1530        assert_eq!(edge2.animations[0].trigger, AnimTrigger::Hover);
1531    }
1532
1533    #[test]
1534    fn roundtrip_edge_with_flow() {
1535        let input = r#"
1536rect @src { w: 50 h: 50 }
1537rect @dst { w: 50 h: 50 }
1538
1539edge @data {
1540  from: @src
1541  to: @dst
1542  arrow: end
1543  flow: pulse 800ms
1544}
1545"#;
1546        let graph = parse_document(input).unwrap();
1547        let edge = &graph.edges[0];
1548        assert!(edge.flow.is_some());
1549        let flow = edge.flow.unwrap();
1550        assert_eq!(flow.kind, FlowKind::Pulse);
1551        assert_eq!(flow.duration_ms, 800);
1552
1553        let output = emit_document(&graph);
1554        let graph2 = parse_document(&output).expect("flow roundtrip failed");
1555        let edge2 = &graph2.edges[0];
1556        let flow2 = edge2.flow.unwrap();
1557        assert_eq!(flow2.kind, FlowKind::Pulse);
1558        assert_eq!(flow2.duration_ms, 800);
1559    }
1560
1561    #[test]
1562    fn roundtrip_edge_dash_flow() {
1563        let input = r#"
1564rect @x { w: 50 h: 50 }
1565rect @y { w: 50 h: 50 }
1566
1567edge @dashed {
1568  from: @x
1569  to: @y
1570  stroke: #EF4444 1
1571  flow: dash 400ms
1572  arrow: both
1573  curve: step
1574}
1575"#;
1576        let graph = parse_document(input).unwrap();
1577        let edge = &graph.edges[0];
1578        let flow = edge.flow.unwrap();
1579        assert_eq!(flow.kind, FlowKind::Dash);
1580        assert_eq!(flow.duration_ms, 400);
1581        assert_eq!(edge.arrow, ArrowKind::Both);
1582        assert_eq!(edge.curve, CurveKind::Step);
1583
1584        let output = emit_document(&graph);
1585        let graph2 = parse_document(&output).expect("dash flow roundtrip failed");
1586        let edge2 = &graph2.edges[0];
1587        let flow2 = edge2.flow.unwrap();
1588        assert_eq!(flow2.kind, FlowKind::Dash);
1589        assert_eq!(flow2.duration_ms, 400);
1590    }
1591
1592    #[test]
1593    fn test_spec_markdown_basic() {
1594        let input = r#"
1595rect @login_btn {
1596  spec {
1597    "Primary CTA for login"
1598    accept: "disabled when fields empty"
1599    status: doing
1600    priority: high
1601    tag: auth
1602  }
1603  w: 280 h: 48
1604  fill: #6C5CE7
1605}
1606"#;
1607        let graph = parse_document(input).unwrap();
1608        let md = emit_spec_markdown(&graph, "login.fd");
1609
1610        assert!(md.starts_with("# Spec: login.fd\n"));
1611        assert!(md.contains("## @login_btn `rect`"));
1612        assert!(md.contains("> Primary CTA for login"));
1613        assert!(md.contains("- [ ] disabled when fields empty"));
1614        assert!(md.contains("- **Status:** doing"));
1615        assert!(md.contains("- **Priority:** high"));
1616        assert!(md.contains("- **Tag:** auth"));
1617        // Visual props must NOT appear
1618        assert!(!md.contains("280"));
1619        assert!(!md.contains("6C5CE7"));
1620    }
1621
1622    #[test]
1623    fn test_spec_markdown_nested() {
1624        let input = r#"
1625group @form {
1626  layout: column gap=16 pad=32
1627  spec {
1628    "Shipping address form"
1629    accept: "autofill from saved addresses"
1630  }
1631
1632  rect @email {
1633    spec {
1634      "Email input"
1635      accept: "validates email format"
1636    }
1637    w: 280 h: 44
1638  }
1639
1640  rect @no_annotations {
1641    w: 100 h: 50
1642    fill: #CCC
1643  }
1644}
1645"#;
1646        let graph = parse_document(input).unwrap();
1647        let md = emit_spec_markdown(&graph, "checkout.fd");
1648
1649        assert!(md.contains("## @form `group`"));
1650        assert!(md.contains("### @email `rect`"));
1651        assert!(md.contains("> Shipping address form"));
1652        assert!(md.contains("- [ ] autofill from saved addresses"));
1653        assert!(md.contains("- [ ] validates email format"));
1654        // Node without annotations should be skipped
1655        assert!(!md.contains("no_annotations"));
1656    }
1657
1658    #[test]
1659    fn test_spec_markdown_with_edges() {
1660        let input = r#"
1661rect @login { w: 200 h: 100 }
1662rect @dashboard {
1663  spec "Main dashboard"
1664  w: 200 h: 100
1665}
1666
1667edge @auth_flow {
1668  spec {
1669    "Authentication flow"
1670    accept: "redirect within 2s"
1671  }
1672  from: @login
1673  to: @dashboard
1674  label: "on success"
1675  arrow: end
1676}
1677"#;
1678        let graph = parse_document(input).unwrap();
1679        let md = emit_spec_markdown(&graph, "flow.fd");
1680
1681        assert!(md.contains("## Flows"));
1682        assert!(md.contains("**@login** → **@dashboard**"));
1683        assert!(md.contains("on success"));
1684        assert!(md.contains("> Authentication flow"));
1685        assert!(md.contains("- [ ] redirect within 2s"));
1686    }
1687
1688    #[test]
1689    fn roundtrip_import_basic() {
1690        let input = "import \"components/buttons.fd\" as btn\nrect @hero { w: 200 h: 100 }\n";
1691        let graph = parse_document(input).unwrap();
1692        assert_eq!(graph.imports.len(), 1);
1693        assert_eq!(graph.imports[0].path, "components/buttons.fd");
1694        assert_eq!(graph.imports[0].namespace, "btn");
1695
1696        let output = emit_document(&graph);
1697        assert!(output.contains("import \"components/buttons.fd\" as btn"));
1698
1699        let graph2 = parse_document(&output).expect("re-parse of import failed");
1700        assert_eq!(graph2.imports.len(), 1);
1701        assert_eq!(graph2.imports[0].path, "components/buttons.fd");
1702        assert_eq!(graph2.imports[0].namespace, "btn");
1703    }
1704
1705    #[test]
1706    fn roundtrip_import_multiple() {
1707        let input = "import \"tokens.fd\" as tokens\nimport \"buttons.fd\" as btn\nrect @box { w: 50 h: 50 }\n";
1708        let graph = parse_document(input).unwrap();
1709        assert_eq!(graph.imports.len(), 2);
1710        assert_eq!(graph.imports[0].namespace, "tokens");
1711        assert_eq!(graph.imports[1].namespace, "btn");
1712
1713        let output = emit_document(&graph);
1714        let graph2 = parse_document(&output).expect("re-parse of multiple imports failed");
1715        assert_eq!(graph2.imports.len(), 2);
1716        assert_eq!(graph2.imports[0].namespace, "tokens");
1717        assert_eq!(graph2.imports[1].namespace, "btn");
1718    }
1719
1720    #[test]
1721    fn parse_import_without_alias_errors() {
1722        let input = "import \"missing_alias.fd\"\nrect @box { w: 50 h: 50 }\n";
1723        // This should fail because "as namespace" is missing
1724        let result = parse_document(input);
1725        assert!(result.is_err());
1726    }
1727
1728    #[test]
1729    fn roundtrip_comment_preserved() {
1730        // A `# comment` before a node should survive parse → emit → parse.
1731        let input = r#"
1732# This is a section header
1733rect @box {
1734  w: 100 h: 50
1735  fill: #FF0000
1736}
1737"#;
1738        let graph = parse_document(input).unwrap();
1739        let output = emit_document(&graph);
1740        assert!(
1741            output.contains("# This is a section header"),
1742            "comment should appear in emitted output: {output}"
1743        );
1744        // Re-parse should also preserve it
1745        let graph2 = parse_document(&output).expect("re-parse of commented document failed");
1746        let node = graph2.get_by_id(NodeId::intern("box")).unwrap();
1747        assert_eq!(node.comments, vec!["This is a section header"]);
1748    }
1749
1750    #[test]
1751    fn roundtrip_multiple_comments_preserved() {
1752        let input = r#"
1753# Header section
1754# Subheading
1755rect @panel {
1756  w: 300 h: 200
1757}
1758"#;
1759        let graph = parse_document(input).unwrap();
1760        let output = emit_document(&graph);
1761        let graph2 = parse_document(&output).expect("re-parse failed");
1762        let node = graph2.get_by_id(NodeId::intern("panel")).unwrap();
1763        assert_eq!(node.comments.len(), 2);
1764        assert_eq!(node.comments[0], "Header section");
1765        assert_eq!(node.comments[1], "Subheading");
1766    }
1767
1768    #[test]
1769    fn roundtrip_inline_position() {
1770        let input = r#"
1771rect @placed {
1772  x: 100
1773  y: 200
1774  w: 50 h: 50
1775  fill: #FF0000
1776}
1777"#;
1778        let graph = parse_document(input).unwrap();
1779        let node = graph.get_by_id(NodeId::intern("placed")).unwrap();
1780
1781        // Should have a Position constraint from x:/y: parsing
1782        assert!(
1783            node.constraints
1784                .iter()
1785                .any(|c| matches!(c, Constraint::Position { .. })),
1786            "should have Position constraint"
1787        );
1788
1789        // Emit and verify x:/y: appear inline (not as top-level arrow)
1790        let output = emit_document(&graph);
1791        assert!(output.contains("x: 100"), "should emit x: inline");
1792        assert!(output.contains("y: 200"), "should emit y: inline");
1793        assert!(
1794            !output.contains("-> absolute"),
1795            "should NOT emit old absolute arrow"
1796        );
1797        assert!(
1798            !output.contains("-> position"),
1799            "should NOT emit position arrow"
1800        );
1801
1802        // Round-trip: re-parse emitted output
1803        let graph2 = parse_document(&output).expect("re-parse of inline position failed");
1804        let node2 = graph2.get_by_id(NodeId::intern("placed")).unwrap();
1805        let pos = node2
1806            .constraints
1807            .iter()
1808            .find_map(|c| match c {
1809                Constraint::Position { x, y } => Some((*x, *y)),
1810                _ => None,
1811            })
1812            .expect("Position constraint missing after roundtrip");
1813        assert_eq!(pos, (100.0, 200.0));
1814    }
1815
1816    #[test]
1817    fn emit_children_before_styles() {
1818        let input = r#"
1819rect @box {
1820  w: 200 h: 100
1821  fill: #FF0000
1822  corner: 10
1823  text @label "Hello" {
1824    fill: #FFFFFF
1825    font: "Inter" 600 14
1826  }
1827  anim :hover {
1828    fill: #CC0000
1829    ease: ease_out 200ms
1830  }
1831}
1832"#;
1833        let graph = parse_document(input).unwrap();
1834        let output = emit_document(&graph);
1835
1836        // Children should appear before inline appearance properties
1837        let child_pos = output.find("text @label").expect("child missing");
1838        let fill_pos = output.find("fill: #FF0000").expect("fill missing");
1839        let corner_pos = output.find("corner: 10").expect("corner missing");
1840        let anim_pos = output.find("when :hover").expect("when missing");
1841
1842        assert!(
1843            child_pos < fill_pos,
1844            "children should appear before fill: child_pos={child_pos} fill_pos={fill_pos}"
1845        );
1846        assert!(
1847            child_pos < corner_pos,
1848            "children should appear before corner"
1849        );
1850        assert!(fill_pos < anim_pos, "fill should appear before animations");
1851    }
1852
1853    #[test]
1854    fn emit_section_separators() {
1855        let input = r#"
1856style accent {
1857  fill: #6C5CE7
1858}
1859
1860rect @a {
1861  w: 100 h: 50
1862}
1863
1864rect @b {
1865  w: 100 h: 50
1866}
1867
1868edge @flow {
1869  from: @a
1870  to: @b
1871  arrow: end
1872}
1873
1874@a -> center_in: canvas
1875"#;
1876        let graph = parse_document(input).unwrap();
1877        let output = emit_document(&graph);
1878
1879        assert!(
1880            output.contains("# ─── Themes ───"),
1881            "should have Themes separator"
1882        );
1883        assert!(
1884            output.contains("# ─── Layout ───"),
1885            "should have Layout separator"
1886        );
1887        assert!(
1888            output.contains("# ─── Flows ───"),
1889            "should have Flows separator"
1890        );
1891    }
1892
1893    #[test]
1894    fn roundtrip_children_before_styles() {
1895        let input = r#"
1896group @card {
1897  layout: column gap=12 pad=20
1898  text @title "Dashboard" {
1899    font: "Inter" 600 20
1900    fill: #111111
1901  }
1902  rect @body {
1903    w: 300 h: 200
1904    fill: #F5F5F5
1905  }
1906  fill: #FFFFFF
1907  corner: 8
1908  shadow: (0,2,8,#00000011)
1909}
1910"#;
1911        let graph = parse_document(input).unwrap();
1912        let output = emit_document(&graph);
1913
1914        // Re-parse the re-ordered output
1915        let graph2 = parse_document(&output).expect("re-parse of reordered output failed");
1916        let card_idx = graph2.index_of(NodeId::intern("card")).unwrap();
1917        assert_eq!(
1918            graph2.children(card_idx).len(),
1919            2,
1920            "card should still have 2 children after roundtrip"
1921        );
1922
1923        // Verify children appear before appearance
1924        let child_pos = output.find("text @title").expect("child missing");
1925        let fill_pos = output.find("fill: #FFFFFF").expect("card fill missing");
1926        assert!(
1927            child_pos < fill_pos,
1928            "children should appear before parent fill"
1929        );
1930    }
1931
1932    #[test]
1933    fn roundtrip_theme_keyword() {
1934        // Verify that `theme` keyword parses and emits correctly
1935        let input = r#"
1936theme accent {
1937  fill: #6C5CE7
1938  corner: 12
1939}
1940
1941rect @btn {
1942  w: 120 h: 40
1943  use: accent
1944}
1945"#;
1946        let graph = parse_document(input).unwrap();
1947        let output = emit_document(&graph);
1948
1949        // Emitter should output `theme`, not `style`
1950        assert!(
1951            output.contains("theme accent"),
1952            "should emit `theme` keyword"
1953        );
1954        assert!(
1955            !output.contains("style accent"),
1956            "should NOT emit `style` keyword"
1957        );
1958
1959        // Round-trip: re-parse emitted output
1960        let graph2 = parse_document(&output).expect("re-parse of theme output failed");
1961        assert!(
1962            graph2.styles.contains_key(&NodeId::intern("accent")),
1963            "theme definition should survive roundtrip"
1964        );
1965    }
1966
1967    #[test]
1968    fn roundtrip_when_keyword() {
1969        // Verify that `when` keyword parses and emits correctly
1970        let input = r#"
1971rect @btn {
1972  w: 120 h: 40
1973  fill: #6C5CE7
1974  when :hover {
1975    fill: #5A4BD1
1976    ease: ease_out 200ms
1977  }
1978}
1979"#;
1980        let graph = parse_document(input).unwrap();
1981        let output = emit_document(&graph);
1982
1983        // Emitter should output `when`, not `anim`
1984        assert!(output.contains("when :hover"), "should emit `when` keyword");
1985        assert!(
1986            !output.contains("anim :hover"),
1987            "should NOT emit `anim` keyword"
1988        );
1989
1990        // Round-trip: re-parse emitted output
1991        let graph2 = parse_document(&output).expect("re-parse of when output failed");
1992        let node = graph2.get_by_id(NodeId::intern("btn")).unwrap();
1993        assert_eq!(
1994            node.animations.len(),
1995            1,
1996            "animation should survive roundtrip"
1997        );
1998        assert_eq!(
1999            node.animations[0].trigger,
2000            AnimTrigger::Hover,
2001            "trigger should be Hover"
2002        );
2003    }
2004
2005    #[test]
2006    fn parse_old_style_keyword_compat() {
2007        // Old `style` keyword must still be accepted by the parser
2008        let input = r#"
2009style accent {
2010  fill: #6C5CE7
2011}
2012
2013rect @btn {
2014  w: 120 h: 40
2015  use: accent
2016}
2017"#;
2018        let graph = parse_document(input).unwrap();
2019        assert!(
2020            graph.styles.contains_key(&NodeId::intern("accent")),
2021            "old `style` keyword should parse into a theme definition"
2022        );
2023
2024        // Emitter should upgrade to `theme`
2025        let output = emit_document(&graph);
2026        assert!(
2027            output.contains("theme accent"),
2028            "emitter should upgrade `style` to `theme`"
2029        );
2030    }
2031
2032    #[test]
2033    fn parse_old_anim_keyword_compat() {
2034        // Old `anim` keyword must still be accepted by the parser
2035        let input = r#"
2036rect @btn {
2037  w: 120 h: 40
2038  fill: #6C5CE7
2039  anim :press {
2040    scale: 0.95
2041    ease: spring 150ms
2042  }
2043}
2044"#;
2045        let graph = parse_document(input).unwrap();
2046        let node = graph.get_by_id(NodeId::intern("btn")).unwrap();
2047        assert_eq!(
2048            node.animations.len(),
2049            1,
2050            "old `anim` keyword should parse into animation"
2051        );
2052        assert_eq!(
2053            node.animations[0].trigger,
2054            AnimTrigger::Press,
2055            "trigger should be Press"
2056        );
2057
2058        // Emitter should upgrade to `when`
2059        let output = emit_document(&graph);
2060        assert!(
2061            output.contains("when :press"),
2062            "emitter should upgrade `anim` to `when`"
2063        );
2064    }
2065
2066    #[test]
2067    fn roundtrip_theme_import() {
2068        // Verify that import + theme references work together
2069        let input = r#"
2070import "tokens.fd" as tokens
2071
2072theme card_base {
2073  fill: #FFFFFF
2074  corner: 16
2075}
2076
2077rect @card {
2078  w: 300 h: 200
2079  use: card_base
2080}
2081"#;
2082        let graph = parse_document(input).unwrap();
2083        let output = emit_document(&graph);
2084
2085        // Both import and theme should appear in output
2086        assert!(
2087            output.contains("import \"tokens.fd\" as tokens"),
2088            "import should survive roundtrip"
2089        );
2090        assert!(
2091            output.contains("theme card_base"),
2092            "theme should survive roundtrip"
2093        );
2094
2095        // Re-parse
2096        let graph2 = parse_document(&output).expect("re-parse failed");
2097        assert_eq!(graph2.imports.len(), 1, "import count should survive");
2098        assert!(
2099            graph2.styles.contains_key(&NodeId::intern("card_base")),
2100            "theme def should survive"
2101        );
2102    }
2103
2104    // ─── Hardening: edge-case round-trip tests ─────────────────────────────
2105
2106    #[test]
2107    fn roundtrip_empty_group() {
2108        let input = "group @empty {\n}\n";
2109        let graph = parse_document(input).unwrap();
2110        let output = emit_document(&graph);
2111        let graph2 = parse_document(&output).expect("re-parse of empty group failed");
2112        let node = graph2.get_by_id(NodeId::intern("empty")).unwrap();
2113        assert!(matches!(node.kind, NodeKind::Group { .. }));
2114    }
2115
2116    #[test]
2117    fn roundtrip_deeply_nested_groups() {
2118        let input = r#"
2119group @outer {
2120  group @middle {
2121    group @inner {
2122      rect @leaf {
2123        w: 40 h: 20
2124        fill: #FF0000
2125      }
2126    }
2127  }
2128}
2129"#;
2130        let graph = parse_document(input).unwrap();
2131        let output = emit_document(&graph);
2132        let graph2 = parse_document(&output).expect("re-parse of nested groups failed");
2133        let leaf = graph2.get_by_id(NodeId::intern("leaf")).unwrap();
2134        assert!(matches!(leaf.kind, NodeKind::Rect { .. }));
2135        // Verify 3-level nesting preserved
2136        let inner_idx = graph2.index_of(NodeId::intern("inner")).unwrap();
2137        assert_eq!(graph2.children(inner_idx).len(), 1);
2138        let middle_idx = graph2.index_of(NodeId::intern("middle")).unwrap();
2139        assert_eq!(graph2.children(middle_idx).len(), 1);
2140    }
2141
2142    #[test]
2143    fn roundtrip_unicode_text() {
2144        let input = "text @emoji \"Hello 🎨 café 日本語\" {\n  fill: #333333\n}\n";
2145        let graph = parse_document(input).unwrap();
2146        let output = emit_document(&graph);
2147        assert!(
2148            output.contains("Hello 🎨 café 日本語"),
2149            "unicode should survive emit"
2150        );
2151        let graph2 = parse_document(&output).expect("re-parse of unicode failed");
2152        let node = graph2.get_by_id(NodeId::intern("emoji")).unwrap();
2153        match &node.kind {
2154            NodeKind::Text { content } => {
2155                assert!(content.contains("🎨"));
2156                assert!(content.contains("café"));
2157                assert!(content.contains("日本語"));
2158            }
2159            _ => panic!("expected Text node"),
2160        }
2161    }
2162
2163    #[test]
2164    fn roundtrip_spec_all_fields() {
2165        let input = r#"
2166rect @full_spec {
2167  spec {
2168    "Full specification node"
2169    accept: "all fields present"
2170    status: doing
2171    priority: high
2172    tag: mvp, auth
2173  }
2174  w: 100 h: 50
2175}
2176"#;
2177        let graph = parse_document(input).unwrap();
2178        let node = graph.get_by_id(NodeId::intern("full_spec")).unwrap();
2179        assert_eq!(node.annotations.len(), 5, "should have 5 annotations");
2180
2181        let output = emit_document(&graph);
2182        let graph2 = parse_document(&output).expect("re-parse of full spec failed");
2183        let node2 = graph2.get_by_id(NodeId::intern("full_spec")).unwrap();
2184        assert_eq!(node2.annotations.len(), 5);
2185        assert_eq!(node2.annotations, node.annotations);
2186    }
2187
2188    #[test]
2189    fn roundtrip_path_node() {
2190        let input = "path @sketch {\n}\n";
2191        let graph = parse_document(input).unwrap();
2192        let output = emit_document(&graph);
2193        let graph2 = parse_document(&output).expect("re-parse of path failed");
2194        let node = graph2.get_by_id(NodeId::intern("sketch")).unwrap();
2195        assert!(matches!(node.kind, NodeKind::Path { .. }));
2196    }
2197
2198    #[test]
2199    fn roundtrip_gradient_linear() {
2200        let input = r#"
2201rect @grad {
2202  w: 200 h: 100
2203  fill: linear(90deg, #FF0000 0, #0000FF 1)
2204}
2205"#;
2206        let graph = parse_document(input).unwrap();
2207        let node = graph.get_by_id(NodeId::intern("grad")).unwrap();
2208        assert!(matches!(
2209            node.style.fill,
2210            Some(Paint::LinearGradient { .. })
2211        ));
2212
2213        let output = emit_document(&graph);
2214        assert!(output.contains("linear("), "should emit linear gradient");
2215        let graph2 = parse_document(&output).expect("re-parse of linear gradient failed");
2216        let node2 = graph2.get_by_id(NodeId::intern("grad")).unwrap();
2217        assert!(matches!(
2218            node2.style.fill,
2219            Some(Paint::LinearGradient { .. })
2220        ));
2221    }
2222
2223    #[test]
2224    fn roundtrip_gradient_radial() {
2225        let input = r#"
2226rect @radial_box {
2227  w: 100 h: 100
2228  fill: radial(#FFFFFF 0, #000000 1)
2229}
2230"#;
2231        let graph = parse_document(input).unwrap();
2232        let node = graph.get_by_id(NodeId::intern("radial_box")).unwrap();
2233        assert!(matches!(
2234            node.style.fill,
2235            Some(Paint::RadialGradient { .. })
2236        ));
2237
2238        let output = emit_document(&graph);
2239        assert!(output.contains("radial("), "should emit radial gradient");
2240        let graph2 = parse_document(&output).expect("re-parse of radial gradient failed");
2241        let node2 = graph2.get_by_id(NodeId::intern("radial_box")).unwrap();
2242        assert!(matches!(
2243            node2.style.fill,
2244            Some(Paint::RadialGradient { .. })
2245        ));
2246    }
2247
2248    #[test]
2249    fn roundtrip_shadow_property() {
2250        let input = r#"
2251rect @shadowed {
2252  w: 200 h: 100
2253  shadow: (0,4,20,#000000)
2254}
2255"#;
2256        let graph = parse_document(input).unwrap();
2257        let node = graph.get_by_id(NodeId::intern("shadowed")).unwrap();
2258        let shadow = node.style.shadow.as_ref().expect("shadow should exist");
2259        assert_eq!(shadow.blur, 20.0);
2260
2261        let output = emit_document(&graph);
2262        assert!(output.contains("shadow:"), "should emit shadow");
2263        let graph2 = parse_document(&output).expect("re-parse of shadow failed");
2264        let node2 = graph2.get_by_id(NodeId::intern("shadowed")).unwrap();
2265        let shadow2 = node2.style.shadow.as_ref().expect("shadow should survive");
2266        assert_eq!(shadow2.offset_y, 4.0);
2267        assert_eq!(shadow2.blur, 20.0);
2268    }
2269
2270    #[test]
2271    fn roundtrip_opacity() {
2272        let input = r#"
2273rect @faded {
2274  w: 100 h: 100
2275  fill: #6C5CE7
2276  opacity: 0.5
2277}
2278"#;
2279        let graph = parse_document(input).unwrap();
2280        let node = graph.get_by_id(NodeId::intern("faded")).unwrap();
2281        assert_eq!(node.style.opacity, Some(0.5));
2282
2283        let output = emit_document(&graph);
2284        let graph2 = parse_document(&output).expect("re-parse of opacity failed");
2285        let node2 = graph2.get_by_id(NodeId::intern("faded")).unwrap();
2286        assert_eq!(node2.style.opacity, Some(0.5));
2287    }
2288
2289    #[test]
2290    fn roundtrip_clip_frame() {
2291        let input = r#"
2292frame @clipped {
2293  w: 300 h: 200
2294  clip: true
2295  fill: #FFFFFF
2296  corner: 12
2297}
2298"#;
2299        let graph = parse_document(input).unwrap();
2300        let output = emit_document(&graph);
2301        assert!(output.contains("clip: true"), "should emit clip");
2302        let graph2 = parse_document(&output).expect("re-parse of clip frame failed");
2303        let node = graph2.get_by_id(NodeId::intern("clipped")).unwrap();
2304        match &node.kind {
2305            NodeKind::Frame { clip, .. } => assert!(clip, "clip should be true"),
2306            _ => panic!("expected Frame node"),
2307        }
2308    }
2309
2310    #[test]
2311    fn roundtrip_multiple_animations() {
2312        let input = r#"
2313rect @animated {
2314  w: 120 h: 40
2315  fill: #6C5CE7
2316  when :hover {
2317    fill: #5A4BD1
2318    scale: 1.05
2319    ease: ease_out 200ms
2320  }
2321  when :press {
2322    scale: 0.95
2323    ease: spring 150ms
2324  }
2325}
2326"#;
2327        let graph = parse_document(input).unwrap();
2328        let node = graph.get_by_id(NodeId::intern("animated")).unwrap();
2329        assert_eq!(node.animations.len(), 2, "should have 2 animations");
2330
2331        let output = emit_document(&graph);
2332        let graph2 = parse_document(&output).expect("re-parse of multi-anim failed");
2333        let node2 = graph2.get_by_id(NodeId::intern("animated")).unwrap();
2334        assert_eq!(node2.animations.len(), 2);
2335        assert_eq!(node2.animations[0].trigger, AnimTrigger::Hover);
2336        assert_eq!(node2.animations[1].trigger, AnimTrigger::Press);
2337    }
2338
2339    #[test]
2340    fn roundtrip_inline_spec_shorthand() {
2341        let input = r#"
2342rect @btn {
2343  spec "Primary action button"
2344  w: 180 h: 48
2345  fill: #6C5CE7
2346}
2347"#;
2348        let graph = parse_document(input).unwrap();
2349        let node = graph.get_by_id(NodeId::intern("btn")).unwrap();
2350        assert_eq!(node.annotations.len(), 1);
2351        assert!(matches!(
2352            &node.annotations[0],
2353            Annotation::Description(d) if d == "Primary action button"
2354        ));
2355
2356        let output = emit_document(&graph);
2357        let graph2 = parse_document(&output).expect("re-parse of inline spec failed");
2358        let node2 = graph2.get_by_id(NodeId::intern("btn")).unwrap();
2359        assert_eq!(node2.annotations, node.annotations);
2360    }
2361
2362    #[test]
2363    fn roundtrip_layout_modes() {
2364        let input = r#"
2365group @col {
2366  layout: column gap=16 pad=24
2367  rect @c1 { w: 100 h: 50 }
2368}
2369
2370group @rw {
2371  layout: row gap=8 pad=12
2372  rect @r1 { w: 50 h: 50 }
2373}
2374
2375group @grd {
2376  layout: grid cols=2 gap=10 pad=20
2377  rect @g1 { w: 80 h: 80 }
2378}
2379"#;
2380        let graph = parse_document(input).unwrap();
2381        let output = emit_document(&graph);
2382        assert!(output.contains("layout: column gap=16 pad=24"));
2383        assert!(output.contains("layout: row gap=8 pad=12"));
2384        assert!(output.contains("layout: grid cols=2 gap=10 pad=20"));
2385
2386        let graph2 = parse_document(&output).expect("re-parse of layout modes failed");
2387        let col = graph2.get_by_id(NodeId::intern("col")).unwrap();
2388        assert!(matches!(
2389            col.kind,
2390            NodeKind::Group {
2391                layout: LayoutMode::Column { .. }
2392            }
2393        ));
2394        let rw = graph2.get_by_id(NodeId::intern("rw")).unwrap();
2395        assert!(matches!(
2396            rw.kind,
2397            NodeKind::Group {
2398                layout: LayoutMode::Row { .. }
2399            }
2400        ));
2401        let grd = graph2.get_by_id(NodeId::intern("grd")).unwrap();
2402        assert!(matches!(
2403            grd.kind,
2404            NodeKind::Group {
2405                layout: LayoutMode::Grid { .. }
2406            }
2407        ));
2408    }
2409
2410    // ─── emit_filtered tests ─────────────────────────────────────────────
2411
2412    fn make_test_graph() -> SceneGraph {
2413        // A rich document with styles, layout, anims, specs, and edges
2414        let input = r#"
2415theme accent {
2416  fill: #6C5CE7
2417  font: "Inter" bold 16
2418}
2419
2420group @container {
2421  layout: column gap=16 pad=24
2422
2423  rect @card {
2424    w: 200 h: 100
2425    use: accent
2426    fill: #FFFFFF
2427    corner: 12
2428    spec {
2429      "Main card component"
2430      status: done
2431    }
2432    when :hover {
2433      fill: #F0EDFF
2434      scale: 1.05
2435      ease: ease_out 200ms
2436    }
2437  }
2438
2439  text @label "Hello" {
2440    font: "Inter" regular 14
2441    fill: #333333
2442    x: 20
2443    y: 40
2444  }
2445}
2446
2447edge @card_to_label {
2448  from: @card
2449  to: @label
2450  label: "displays"
2451}
2452"#;
2453        parse_document(input).unwrap()
2454    }
2455
2456    #[test]
2457    fn emit_filtered_full_matches_emit_document() {
2458        let graph = make_test_graph();
2459        let full = emit_filtered(&graph, ReadMode::Full);
2460        let doc = emit_document(&graph);
2461        assert_eq!(full, doc, "Full mode should be identical to emit_document");
2462    }
2463
2464    #[test]
2465    fn emit_filtered_structure() {
2466        let graph = make_test_graph();
2467        let out = emit_filtered(&graph, ReadMode::Structure);
2468        // Should have node declarations
2469        assert!(out.contains("group @container"), "should include group");
2470        assert!(out.contains("rect @card"), "should include rect");
2471        assert!(out.contains("text @label"), "should include text");
2472        // Should NOT have styles, dimensions, specs, or anims
2473        assert!(!out.contains("fill:"), "no fill in structure mode");
2474        assert!(!out.contains("w:"), "no dimensions in structure mode");
2475        assert!(!out.contains("spec"), "no spec in structure mode");
2476        assert!(!out.contains("when"), "no when in structure mode");
2477        assert!(!out.contains("theme"), "no theme in structure mode");
2478        assert!(!out.contains("edge"), "no edges in structure mode");
2479    }
2480
2481    #[test]
2482    fn emit_filtered_layout() {
2483        let graph = make_test_graph();
2484        let out = emit_filtered(&graph, ReadMode::Layout);
2485        // Should have layout + dimensions
2486        assert!(out.contains("layout: column"), "should include layout");
2487        assert!(out.contains("w: 200 h: 100"), "should include dims");
2488        assert!(out.contains("x: 20"), "should include position");
2489        // Should NOT have styles or anims
2490        assert!(!out.contains("fill:"), "no fill in layout mode");
2491        assert!(!out.contains("theme"), "no theme in layout mode");
2492        assert!(!out.contains("when :hover"), "no when in layout mode");
2493    }
2494
2495    #[test]
2496    fn emit_filtered_design() {
2497        let graph = make_test_graph();
2498        let out = emit_filtered(&graph, ReadMode::Design);
2499        // Should have themes + styles
2500        assert!(out.contains("theme accent"), "should include theme");
2501        assert!(out.contains("use: accent"), "should include use ref");
2502        assert!(out.contains("fill:"), "should include fill");
2503        assert!(out.contains("corner: 12"), "should include corner");
2504        // Should NOT have layout or anims
2505        assert!(!out.contains("layout:"), "no layout in design mode");
2506        assert!(!out.contains("w: 200"), "no dims in design mode");
2507        assert!(!out.contains("when :hover"), "no when in design mode");
2508    }
2509
2510    #[test]
2511    fn emit_filtered_spec() {
2512        let graph = make_test_graph();
2513        let out = emit_filtered(&graph, ReadMode::Spec);
2514        // Should have spec blocks
2515        assert!(out.contains("spec"), "should include spec");
2516        assert!(out.contains("Main card component"), "should include desc");
2517        assert!(out.contains("status: done"), "should include status");
2518        // Should NOT have styles or anims
2519        assert!(!out.contains("fill:"), "no fill in spec mode");
2520        assert!(!out.contains("when"), "no when in spec mode");
2521    }
2522
2523    #[test]
2524    fn emit_filtered_visual() {
2525        let graph = make_test_graph();
2526        let out = emit_filtered(&graph, ReadMode::Visual);
2527        // Visual = Layout + Design + When
2528        assert!(out.contains("theme accent"), "should include theme");
2529        assert!(out.contains("layout: column"), "should include layout");
2530        assert!(out.contains("w: 200 h: 100"), "should include dims");
2531        assert!(out.contains("fill:"), "should include fill");
2532        assert!(out.contains("corner: 12"), "should include corner");
2533        assert!(out.contains("when :hover"), "should include when");
2534        assert!(out.contains("scale: 1.05"), "should include anim props");
2535        assert!(out.contains("edge @card_to_label"), "should include edges");
2536        // Should NOT have spec blocks
2537        assert!(
2538            !out.contains("Main card component"),
2539            "no spec desc in visual mode"
2540        );
2541    }
2542
2543    #[test]
2544    fn emit_filtered_when() {
2545        let graph = make_test_graph();
2546        let out = emit_filtered(&graph, ReadMode::When);
2547        // Should have when blocks
2548        assert!(out.contains("when :hover"), "should include when");
2549        assert!(out.contains("scale: 1.05"), "should include anim props");
2550        // Should NOT have node-level styles, layout, or spec
2551        assert!(!out.contains("corner:"), "no corner in when mode");
2552        assert!(!out.contains("w: 200"), "no dims in when mode");
2553        assert!(!out.contains("theme"), "no theme in when mode");
2554        assert!(!out.contains("spec"), "no spec in when mode");
2555    }
2556
2557    #[test]
2558    fn emit_filtered_edges() {
2559        let graph = make_test_graph();
2560        let out = emit_filtered(&graph, ReadMode::Edges);
2561        // Should have edges
2562        assert!(out.contains("edge @card_to_label"), "should include edge");
2563        assert!(out.contains("from: @card"), "should include from");
2564        assert!(out.contains("to: @label"), "should include to");
2565        assert!(out.contains("label: \"displays\""), "should include label");
2566        // Should NOT have styles or anims
2567        assert!(!out.contains("fill:"), "no fill in edges mode");
2568        assert!(!out.contains("when"), "no when in edges mode");
2569    }
2570}