Skip to main content

fd_core/
emitter.rs

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