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