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