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