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