Skip to main content

tauri_plugin_widgets/
adaptive_card.rs

1//! IR → Adaptive Card 1.5 transpiler for Windows Widgets Board.
2//!
3//! Pure Rust — runs on any host. Elements Adaptive Cards cannot express
4//! (`canvas`, `chart`, `gauge`, `zstack`, …) become empty placeholders and are
5//! recorded in [`TranspileResult::skipped`].
6
7use crate::models::{
8    ColorValue, ElementStyle, FontWeight, TextAlignment, WidgetConfig, WidgetElement,
9    VStackElement, HStackElement, ZStackElement, GridElement, ContainerElement, TextElement, ImageElement, ProgressElement, GaugeElement, ButtonElement, ToggleElement, DividerElement, DateElement, ChartElement, ListElement, LinkElement, ShapeElement, TimerElement, CanvasElement, LabelElement,
10};
11use crate::receipt::SkippedElement;
12use serde_json::{json, Value};
13
14/// Result of transpiling one IR root (plus skipped diagnostics).
15#[derive(Debug, Clone)]
16pub struct TranspileResult {
17    pub card: Value,
18    pub skipped: Vec<SkippedElement>,
19}
20
21/// Build an Adaptive Card 1.5 document from a root element.
22pub fn to_adaptive_card(root: &WidgetElement) -> TranspileResult {
23    let mut skipped = Vec::new();
24    let body = el(root, &mut skipped);
25    let card = json!({
26        "type": "AdaptiveCard",
27        "$schema": "http://adaptivecards.io/schemas/adaptive-card.json",
28        "version": "1.5",
29        "body": [body],
30    });
31    TranspileResult { card, skipped }
32}
33
34/// Pick a size branch from [`WidgetConfig`] and transpile.
35pub fn to_adaptive_card_for_size(config: &WidgetConfig, size: &str) -> Option<TranspileResult> {
36    let root = match size {
37        "large" => config
38            .large
39            .as_ref()
40            .or(config.medium.as_ref())
41            .or(config.small.as_ref()),
42        "medium" => config
43            .medium
44            .as_ref()
45            .or(config.large.as_ref())
46            .or(config.small.as_ref()),
47        _ => config
48            .small
49            .as_ref()
50            .or(config.medium.as_ref())
51            .or(config.large.as_ref()),
52    }?;
53    Some(to_adaptive_card(root))
54}
55
56/// Structural check (no full JSON Schema dependency).
57pub fn validate_card_structure(card: &Value) -> Result<(), String> {
58    let obj = card
59        .as_object()
60        .ok_or_else(|| "card must be an object".to_string())?;
61    if obj.get("type").and_then(|v| v.as_str()) != Some("AdaptiveCard") {
62        return Err("type must be AdaptiveCard".into());
63    }
64    if obj.get("version").and_then(|v| v.as_str()) != Some("1.5") {
65        return Err("version must be 1.5".into());
66    }
67    match obj.get("body") {
68        Some(Value::Array(_)) => Ok(()),
69        _ => Err("body must be an array".into()),
70    }
71}
72
73/// Storage keys written next to the config map for the C# provider.
74pub fn ac_template_key(widget_id: &str) -> String {
75    format!("ac:template:{widget_id}")
76}
77
78pub fn ac_data_key(widget_id: &str) -> String {
79    format!("ac:data:{widget_id}")
80}
81
82fn el(e: &WidgetElement, skipped: &mut Vec<SkippedElement>) -> Value {
83    match e {
84        WidgetElement::VStack(VStackElement {
85            children,
86            style,
87            alignment,
88            spacing,
89            ..
90        }) => {
91            let mut obj = json!({
92                "type": "Container",
93                "spacing": "None",
94                "items": children.iter().map(|c| el(c, skipped)).collect::<Vec<_>>(),
95            });
96            apply_container_style(&mut obj, style);
97            apply_gap_id(&mut obj, *spacing);
98            // HorizontalAlignment on VStack children (center/leading/trailing).
99            if let Some(a) = alignment {
100                match a {
101                    crate::models::HorizontalAlignment::Center => {
102                        obj["horizontalAlignment"] = json!("Center");
103                    }
104                    crate::models::HorizontalAlignment::Trailing => {
105                        obj["horizontalAlignment"] = json!("Right");
106                    }
107                    _ => {}
108                }
109            }
110            obj
111        }
112
113        WidgetElement::HStack(HStackElement {
114            children, spacing, ..
115        }) => {
116            let n = children.len();
117            let mut obj = json!({
118                "type": "ColumnSet",
119                "columns": children
120                    .iter()
121                    .enumerate()
122                    .map(|(i, c)| {
123                        let (width, items) = match c {
124                            WidgetElement::Spacer(_) => (json!("stretch"), json!([])),
125                            // Column immediately before a spacer expands (title + Sync pattern).
126                            _ if i + 1 < n
127                                && matches!(children[i + 1], WidgetElement::Spacer(_)) =>
128                            {
129                                (json!("stretch"), json!([el(c, skipped)]))
130                            }
131                            _ => (flex_width(c), json!([el(c, skipped)])),
132                        };
133                        json!({
134                            "type": "Column",
135                            "width": width,
136                            "items": items,
137                        })
138                    })
139                    .collect::<Vec<_>>(),
140            });
141            apply_gap_id(&mut obj, *spacing);
142            obj
143        }
144
145        WidgetElement::Container(ContainerElement {
146            children,
147            style,
148            content_alignment,
149            ..
150        }) => {
151            let mut obj = json!({
152                "type": "Container",
153                "spacing": "None",
154                "items": children.iter().map(|c| el(c, skipped)).collect::<Vec<_>>(),
155            });
156            apply_container_style(&mut obj, style);
157            if content_alignment
158                .as_deref()
159                .map(|a| a.to_ascii_lowercase().contains("center"))
160                .unwrap_or(false)
161            {
162                obj["horizontalAlignment"] = json!("Center");
163            }
164            obj
165        }
166
167        WidgetElement::Grid(GridElement {
168            children,
169            columns,
170            spacing,
171            row_spacing,
172            ..
173        }) => {
174            let cols = (*columns).max(1) as usize;
175            let mut rows = Vec::new();
176            for chunk in children.chunks(cols) {
177                let columns_json: Vec<Value> = chunk
178                    .iter()
179                    .map(|c| {
180                        json!({
181                            "type": "Column",
182                            "width": "stretch",
183                            "items": [el(c, skipped)],
184                        })
185                    })
186                    .collect();
187                let mut row = json!({
188                    "type": "ColumnSet",
189                    "columns": columns_json,
190                });
191                apply_gap_id(&mut row, *spacing);
192                rows.push(row);
193            }
194            let mut obj = json!({
195                "type": "Container",
196                "spacing": "None",
197                "items": rows,
198            });
199            apply_gap_id(&mut obj, row_spacing.or(*spacing));
200            obj
201        }
202
203        WidgetElement::Text(TextElement {
204            content,
205            font_size,
206            font_weight,
207            color,
208            alignment,
209            line_limit,
210            ..
211        }) => {
212            let wrap = !matches!(line_limit, Some(n) if *n <= 1);
213            let mut obj = json!({
214                "type": "TextBlock",
215                "text": content,
216                "wrap": wrap,
217                "size": size_bucket(*font_size),
218                "weight": weight_token(font_weight.as_ref()),
219            });
220            if let Some(c) =
221                ac_color(color.as_ref()).or_else(|| approx_hex_semantic(color.as_ref()))
222            {
223                obj["color"] = json!(c);
224            }
225            // Preserve exact hex for composite (muted slates stay slate, not AC "Light").
226            if let Some(hex) = color_hex(color.as_ref()) {
227                obj["id"] = json!(format!("fg:{hex}"));
228            }
229            if let Some(a) = align(alignment.as_ref()) {
230                obj["horizontalAlignment"] = json!(a);
231            }
232            obj
233        }
234
235        WidgetElement::Image(ImageElement {
236            url,
237            data,
238            system_name,
239            size,
240            color,
241            ..
242        }) => {
243            let url_str = url.clone().unwrap_or_else(|| {
244                let b64 = data.clone().unwrap_or_default();
245                if b64.is_empty() {
246                    String::new()
247                } else if b64.starts_with("data:") {
248                    b64
249                } else {
250                    format!("data:image/png;base64,{b64}")
251                }
252            });
253            if url_str.is_empty() {
254                // SF Symbol / Material name without bitmap → emoji TextBlock for AC/goldens.
255                let glyph = system_name.as_deref().map(sf_symbol_glyph).unwrap_or("•");
256                let mut obj = json!({
257                    "type": "TextBlock",
258                    "text": glyph,
259                    "wrap": true,
260                    "size": size_bucket(size.map(|s| s * 0.75)),
261                    "weight": "Bolder",
262                });
263                if let Some(c) =
264                    ac_color(color.as_ref()).or_else(|| approx_hex_semantic(color.as_ref()))
265                {
266                    obj["color"] = json!(c);
267                }
268                return obj;
269            }
270            json!({
271                "type": "Image",
272                "url": url_str,
273                "size": img_size_bucket(*size),
274            })
275        }
276
277        WidgetElement::Button(ButtonElement {
278            label,
279            action,
280            url,
281            background_color,
282            color,
283            ..
284        }) => {
285            let title = sanitize_button_label(label);
286            let mut action_json = match action {
287                Some(a) => json!({
288                    "type": "Action.Execute",
289                    "title": title,
290                    "verb": a,
291                }),
292                None => json!({
293                    "type": "Action.OpenUrl",
294                    "title": title,
295                    "url": url.clone().unwrap_or_default(),
296                }),
297            };
298            // Composite / PreviewHost: pass fill+fg via id (AC has no arbitrary action colors).
299            let mut id_parts = Vec::new();
300            if let Some(hex) = color_hex(background_color.as_ref()) {
301                id_parts.push(format!("bg:{hex}"));
302            }
303            if let Some(hex) = color_hex(color.as_ref()) {
304                id_parts.push(format!("fg:{hex}"));
305            }
306            if !id_parts.is_empty() {
307                action_json["id"] = json!(id_parts.join(";"));
308            }
309            json!({
310                "type": "ActionSet",
311                "actions": [action_json],
312            })
313        }
314
315        WidgetElement::Toggle(ToggleElement {
316            is_on,
317            label,
318            action,
319            ..
320        }) => {
321            // Prefer readable TextBlock for Adaptive Cards / goldens (ActionSet is clickable but low-fidelity).
322            let title =
323                label
324                    .clone()
325                    .unwrap_or_else(|| if *is_on { "On".into() } else { "Off".into() });
326            let mark = if *is_on { "✓" } else { "○" };
327            let mut block = json!({
328                "type": "TextBlock",
329                "text": format!("{mark} {title}"),
330                "wrap": true,
331                "size": "Default",
332            });
333                    if let Some(a) = action {
334                // Keep toggle tappable on Widgets Board.
335                // Payload is the scalar next-state string (matches iOS/Android/desktop).
336                return json!({
337                    "type": "Container",
338                    "items": [block],
339                    "selectAction": {
340                        "type": "Action.Execute",
341                        "verb": a,
342                        "data": { "payload": (!is_on).to_string() },
343                    },
344                });
345            }
346            let _ = &mut block;
347            block
348        }
349
350        WidgetElement::Progress(ProgressElement {
351            value,
352            total,
353            label,
354            tint,
355            color,
356            ..
357        }) => {
358            let total = if *total <= 0.0 { 1.0 } else { *total };
359            let pct = ((*value / total) * 100.0).clamp(0.0, 100.0) as u32;
360            let rest = 100u32.saturating_sub(pct);
361            let mut items = Vec::new();
362            if let Some(l) = label {
363                let mut tb = json!({
364                    "type": "TextBlock",
365                    "text": l,
366                    "size": "Small",
367                    "wrap": true,
368                });
369                if let Some(c) = ac_color(color.as_ref())
370                    .or_else(|| ac_color(tint.as_ref()))
371                    .or_else(|| approx_hex_semantic(color.as_ref()))
372                    .or_else(|| approx_hex_semantic(tint.as_ref()))
373                {
374                    tb["color"] = json!(c);
375                }
376                items.push(tb);
377            }
378            let fill_hex = color_hex(tint.as_ref());
379            let fill_style = approx_hex_semantic(tint.as_ref())
380                .unwrap_or("Good")
381                .to_ascii_lowercase();
382            let mut filled = json!({
383                "type": "Column",
384                "width": pct.max(1),
385                "style": fill_style,
386                "items": []
387            });
388            if let Some(ref hex) = fill_hex {
389                filled["id"] = json!(format!("fill:{hex}"));
390            }
391            items.push(json!({
392                "type": "ColumnSet",
393                "columns": [
394                    filled,
395                    {
396                        "type": "Column",
397                        "width": rest.max(1),
398                        "items": []
399                    }
400                ],
401            }));
402            json!({
403                "type": "Container",
404                "items": items,
405            })
406        }
407
408        WidgetElement::Divider(DividerElement { color, .. }) => {
409            let mut obj = json!({
410                "type": "Container",
411                "separator": true,
412                "spacing": "Medium",
413                "items": [],
414            });
415            if let Some(hex) = color_hex(color.as_ref()) {
416                obj["id"] = json!(format!("rule:{hex}"));
417            } else {
418                obj["id"] = json!("rule:#334155");
419            }
420            obj
421        }
422
423        WidgetElement::Spacer(_) => json!({
424            "type": "TextBlock",
425            "text": " ",
426            "spacing": "Medium",
427        }),
428
429        WidgetElement::Date(DateElement { date, .. }) => json!({
430            "type": "TextBlock",
431            "text": date,
432            "wrap": true,
433            "size": "Default",
434        }),
435
436        WidgetElement::Timer(TimerElement { target_date, .. }) => json!({
437            "type": "TextBlock",
438            "text": target_date,
439            "wrap": true,
440            "size": "Default",
441        }),
442
443        WidgetElement::Label(LabelElement {
444            text,
445            system_name,
446            color,
447            ..
448        }) => {
449            let prefix = if system_name.is_empty() {
450                String::new()
451            } else {
452                format!("{} ", sf_symbol_glyph(system_name))
453            };
454            let mut obj = json!({
455                "type": "TextBlock",
456                "text": format!("{prefix}{text}"),
457                "wrap": true,
458                "weight": "Bolder",
459                "size": "Small",
460            });
461            if let Some(c) =
462                ac_color(color.as_ref()).or_else(|| approx_hex_semantic(color.as_ref()))
463            {
464                obj["color"] = json!(c);
465            } else {
466                obj["color"] = json!("Light");
467            }
468            obj
469        }
470
471        WidgetElement::Link(LinkElement {
472            children,
473            action,
474            url,
475            style,
476            ..
477        }) => {
478            let inner: Vec<Value> = children.iter().map(|c| el(c, skipped)).collect();
479            let mut container = json!({
480                "type": "Container",
481                "items": inner,
482                "selectAction": match action {
483                    Some(a) => json!({ "type": "Action.Execute", "verb": a }),
484                    None => json!({
485                        "type": "Action.OpenUrl",
486                        "url": url.clone().unwrap_or_default(),
487                    }),
488                },
489            });
490            apply_container_style(&mut container, style);
491            container
492        }
493
494        WidgetElement::List(ListElement { items, spacing, .. }) => {
495            // ColumnSet mark | text keeps a shared left edge regardless of glyph width.
496            let row_gap = spacing.or(Some(4.0));
497            let lines: Vec<Value> = items
498                .iter()
499                .enumerate()
500                .map(|(idx, it)| {
501                    let (mark, mark_color) = match it.checked {
502                        Some(true) => ("✓", "Good"),
503                        Some(false) => ("○", "Light"),
504                        // Keep a blank mark slot so labels share a left edge.
505                        None => (" ", "Light"),
506                    };
507                    let row = json!({
508                        "type": "ColumnSet",
509                        "columns": [
510                            {
511                                "type": "Column",
512                                "width": "auto",
513                                "id": "mark:col",
514                                "items": [{
515                                    "type": "TextBlock",
516                                    "text": mark,
517                                    "wrap": false,
518                                    "size": "Small",
519                                    "color": mark_color,
520                                    "horizontalAlignment": "Center",
521                                }]
522                            },
523                            {
524                                "type": "Column",
525                                "width": "stretch",
526                                "items": [{
527                                    "type": "TextBlock",
528                                    "text": it.text,
529                                    "wrap": true,
530                                    "size": "Small",
531                                    "horizontalAlignment": "Left",
532                                    "color": "Light",
533                                }]
534                            }
535                        ]
536                    });
537                    // ColumnSet has no selectAction in AC 1.5 — wrap in Container.
538                    let mut item = if let Some(ref a) = it.action {
539                        json!({
540                            "type": "Container",
541                            "items": [row],
542                            "selectAction": {
543                                "type": "Action.Execute",
544                                "verb": a,
545                                "data": { "payload": it.payload },
546                            }
547                        })
548                    } else {
549                        row
550                    };
551                    // Spacing between rows (not only outer wrapper).
552                    if idx > 0 {
553                        apply_gap_id(&mut item, row_gap);
554                    }
555                    item
556                })
557                .collect();
558            json!({
559                "type": "Container",
560                "horizontalAlignment": "Left",
561                "spacing": "None",
562                "items": lines,
563            })
564        }
565
566        WidgetElement::Shape(_) => rasterized_or_skip(e, skipped),
567
568        WidgetElement::ZStack(ZStackElement {
569            children,
570            style,
571            alignment,
572            ..
573        }) => {
574            // Degraded: no true overlay — flatten; keep center hint for composite.
575            let mut obj = json!({
576                "type": "Container",
577                "spacing": "None",
578                "items": children.iter().map(|c| el(c, skipped)).collect::<Vec<_>>(),
579            });
580            apply_container_style(&mut obj, style);
581            let centered = alignment
582                .as_deref()
583                .map(|a| a.to_ascii_lowercase().contains("center"))
584                .unwrap_or(false);
585            if centered {
586                obj["horizontalAlignment"] = json!("Center");
587            }
588            // Icon badge (shape/image + glyph text): mark for overlay+center in composite.
589            if let Some(items) = obj.get("items").and_then(|i| i.as_array()) {
590                if items.len() == 2 {
591                    obj["id"] = json!("badge:overlay");
592                }
593            }
594            obj
595        }
596
597        WidgetElement::Gauge(_)
598        | WidgetElement::Chart(_)
599        | WidgetElement::Canvas(_) => rasterized_or_skip(e, skipped),
600    }
601}
602
603fn rasterized_or_skip(e: &WidgetElement, skipped: &mut Vec<SkippedElement>) -> Value {
604    let ty = e.type_name();
605    match crate::rasterize::element_to_png_data_uri(e) {
606        Ok(uri) => {
607            let mut img = json!({
608                "type": "Image",
609                "url": uri,
610                "size": "Medium",
611                "horizontalAlignment": "Center",
612            });
613            match e {
614                WidgetElement::Canvas(_)
615                | WidgetElement::Chart(_)
616                | WidgetElement::Gauge(_) => {
617                    img["size"] = json!("Stretch");
618                }
619                WidgetElement::Shape(ShapeElement { size, .. }) => {
620                    img["size"] = json!(img_size_bucket(*size));
621                }
622                _ => {}
623            }
624            img
625        }
626        Err(err) => {
627            skipped.push(SkippedElement {
628                type_name: ty.into(),
629                reason: format!("rasterize failed: {err}"),
630            });
631            json!({
632                "type": "TextBlock",
633                "text": "",
634                "spacing": "None",
635            })
636        }
637    }
638}
639
640fn flex_width(c: &WidgetElement) -> Value {
641    flex_of(c)
642        .map(|f| {
643            if f <= 0.0 {
644                json!("auto")
645            } else {
646                json!((f * 100.0).round() as u64)
647            }
648        })
649        .unwrap_or(json!("auto"))
650}
651
652fn flex_of(e: &WidgetElement) -> Option<f64> {
653    match e {
654        WidgetElement::VStack(VStackElement { style, .. })
655        | WidgetElement::HStack(HStackElement { style, .. })
656        | WidgetElement::ZStack(ZStackElement { style, .. })
657        | WidgetElement::Grid(GridElement { style, .. })
658        | WidgetElement::Container(ContainerElement { style, .. })
659        | WidgetElement::Text(TextElement { style, .. })
660        | WidgetElement::Image(ImageElement { style, .. })
661        | WidgetElement::Progress(ProgressElement { style, .. })
662        | WidgetElement::Gauge(GaugeElement { style, .. })
663        | WidgetElement::Button(ButtonElement { style, .. })
664        | WidgetElement::Toggle(ToggleElement { style, .. })
665        | WidgetElement::Divider(DividerElement { style, .. })
666        | WidgetElement::Date(DateElement { style, .. })
667        | WidgetElement::Chart(ChartElement { style, .. })
668        | WidgetElement::List(ListElement { style, .. })
669        | WidgetElement::Link(LinkElement { style, .. })
670        | WidgetElement::Shape(ShapeElement { style, .. })
671        | WidgetElement::Timer(TimerElement { style, .. })
672        | WidgetElement::Canvas(CanvasElement { style, .. })
673        | WidgetElement::Label(LabelElement { style, .. }) => style.flex,
674        WidgetElement::Spacer(_) => None,
675    }
676}
677
678fn push_id_token(obj: &mut Value, token: &str) {
679    match obj.get("id").and_then(|v| v.as_str()) {
680        Some(id) if !id.is_empty() => {
681            if id.split(';').any(|p| p == token) {
682                return;
683            }
684            obj["id"] = json!(format!("{id};{token}"));
685        }
686        _ => {
687            obj["id"] = json!(token);
688        }
689    }
690}
691
692fn apply_gap_id(obj: &mut Value, spacing: Option<f64>) {
693    let Some(s) = spacing else {
694        return;
695    };
696    if s <= 0.0 {
697        return;
698    }
699    // Adaptive Cards layout spacing (pt buckets) — keep gap id for debug tooling.
700    let ac = if s < 4.0 {
701        "Small"
702    } else if s < 10.0 {
703        "Default"
704    } else if s < 16.0 {
705        "Medium"
706    } else {
707        "Large"
708    };
709    obj["spacing"] = json!(ac);
710    push_id_token(obj, &format!("gap:{}", s.round() as i64));
711}
712
713fn apply_container_style(obj: &mut Value, style: &ElementStyle) {
714    if let Some(bg) = &style.background {
715        // Adaptive Cards only has emphasis / good / attention / warning / accent / default.
716        obj["style"] = json!("emphasis");
717        if let Some(hex) = background_hex(bg) {
718            push_id_token(obj, &format!("card:{hex}"));
719        }
720    }
721}
722
723fn background_hex(bg: &crate::models::BackgroundValue) -> Option<String> {
724    match bg {
725        crate::models::BackgroundValue::Solid(s) => {
726            let s = s.trim();
727            if s.starts_with('#') {
728                Some(s.to_ascii_lowercase())
729            } else {
730                None
731            }
732        }
733        crate::models::BackgroundValue::Adaptive { dark, .. } => {
734            let s = dark.trim();
735            if s.starts_with('#') {
736                Some(s.to_ascii_lowercase())
737            } else {
738                None
739            }
740        }
741        crate::models::BackgroundValue::Gradient(g) => g
742            .colors
743            .first()
744            .map(|c| c.trim().to_ascii_lowercase())
745            .filter(|c| c.starts_with('#')),
746    }
747}
748
749fn size_bucket(font_size: Option<f64>) -> &'static str {
750    match font_size {
751        Some(s) if s <= 12.0 => "Small",
752        Some(s) if s <= 16.0 => "Default",
753        Some(s) if s <= 22.0 => "Medium",
754        Some(s) if s <= 32.0 => "Large",
755        Some(_) => "ExtraLarge",
756        None => "Default",
757    }
758}
759
760fn img_size_bucket(size: Option<f64>) -> &'static str {
761    match size {
762        Some(s) if s <= 24.0 => "Small",
763        Some(s) if s <= 48.0 => "Medium",
764        Some(_) => "Large",
765        None => "Medium",
766    }
767}
768
769fn weight_token(w: Option<&FontWeight>) -> &'static str {
770    match w {
771        Some(FontWeight::Bold)
772        | Some(FontWeight::Semibold)
773        | Some(FontWeight::Heavy)
774        | Some(FontWeight::Black) => "Bolder",
775        Some(FontWeight::Light) | Some(FontWeight::Thin) | Some(FontWeight::Ultralight) => {
776            "Lighter"
777        }
778        _ => "Default",
779    }
780}
781
782fn align(a: Option<&TextAlignment>) -> Option<&'static str> {
783    match a {
784        Some(TextAlignment::Leading) => Some("Left"),
785        Some(TextAlignment::Center) => Some("Center"),
786        Some(TextAlignment::Trailing) => Some("Right"),
787        None => None,
788    }
789}
790
791/// Map IR colors to Adaptive Card semantic tokens; hex → omit (caller may approx).
792fn ac_color(color: Option<&ColorValue>) -> Option<&'static str> {
793    let raw = match color? {
794        ColorValue::Solid(s) => s.as_str(),
795        ColorValue::Adaptive { light, .. } => light.as_str(),
796    };
797    let lower = raw.to_ascii_lowercase();
798    match lower.as_str() {
799        "accent" => Some("Accent"),
800        "good" | "success" => Some("Good"),
801        "warning" => Some("Warning"),
802        "attention" | "error" | "danger" => Some("Attention"),
803        // "Dark" text on dark Widgets Board / goldens is invisible — use Default.
804        "label" | "dark" => Some("Default"),
805        "secondarylabel" | "light" => Some("Light"),
806        "default" => Some("Default"),
807        s if s.starts_with('#') => None,
808        _ => None,
809    }
810}
811
812fn color_hex(color: Option<&ColorValue>) -> Option<String> {
813    let raw = match color? {
814        ColorValue::Solid(s) => s.as_str(),
815        ColorValue::Adaptive { light, .. } => light.as_str(),
816    };
817    let s = raw.trim();
818    if s.starts_with('#') && (s.len() == 7 || s.len() == 4) {
819        Some(s.to_ascii_lowercase())
820    } else {
821        None
822    }
823}
824
825/// Approximate hex → AC semantic (for labels / progress style hints).
826fn approx_hex_semantic(color: Option<&ColorValue>) -> Option<&'static str> {
827    let hex = color_hex(color)?;
828    let (r, g, b) = parse_hex_rgb(&hex)?;
829    let max = r.max(g).max(b) as f32;
830    let min = r.min(g).min(b) as f32;
831    let luma = 0.299 * r as f32 + 0.587 * g as f32 + 0.114 * b as f32;
832    let sat = max - min;
833    // Near-black → light text on dark goldens.
834    if luma < 60.0 {
835        return Some("Light");
836    }
837    // Low-saturation slate/gray (e.g. #94a3b8) → Light, not Accent.
838    if sat < 50.0 {
839        return Some("Light");
840    }
841    if luma > 200.0 && sat < 30.0 {
842        return Some("Light");
843    }
844    let (rf, gf, bf) = (r as f32, g as f32, b as f32);
845    if rf > gf + 30.0 && rf > bf + 30.0 {
846        return Some("Attention");
847    }
848    if gf > rf + 20.0 && gf > bf + 20.0 {
849        return Some("Good");
850    }
851    if bf > rf + 20.0 && bf > gf + 10.0 {
852        return Some("Accent");
853    }
854    if rf > 180.0 && gf > 120.0 && bf < 100.0 {
855        return Some("Warning");
856    }
857    Some("Default")
858}
859
860fn parse_hex_rgb(hex: &str) -> Option<(u8, u8, u8)> {
861    let h = hex.trim_start_matches('#');
862    if h.len() == 3 {
863        let r = u8::from_str_radix(&h[0..1].repeat(2), 16).ok()?;
864        let g = u8::from_str_radix(&h[1..2].repeat(2), 16).ok()?;
865        let b = u8::from_str_radix(&h[2..3].repeat(2), 16).ok()?;
866        return Some((r, g, b));
867    }
868    if h.len() == 6 {
869        let r = u8::from_str_radix(&h[0..2], 16).ok()?;
870        let g = u8::from_str_radix(&h[2..4], 16).ok()?;
871        let b = u8::from_str_radix(&h[4..6], 16).ok()?;
872        return Some((r, g, b));
873    }
874    None
875}
876
877/// Media / SF-ish glyphs often missing in SVG fonts → ASCII stand-ins.
878fn sanitize_button_label(label: &str) -> String {
879    let t = label.trim();
880    match t {
881        "⏮" | "⏮️" => "Prev".into(),
882        "⏭" | "⏭️" => "Next".into(),
883        "⏸" | "⏸️" => "Pause".into(),
884        "▶" | "▶️" | "⏯" => "Play".into(),
885        "⏹" | "⏹️" => "Stop".into(),
886        "⌫" => "BS".into(),
887        "±" => "+/-".into(),
888        "÷" => "/".into(),
889        "×" => "x".into(),
890        "−" => "-".into(),
891        _ => {
892            let mut s = t.to_string();
893            for (from, to) in [
894                ("⏮", "Prev"),
895                ("⏭", "Next"),
896                ("⏸", "Pause"),
897                ("▶", "Play"),
898                ("⌫", "BS"),
899                ("±", "+/-"),
900                ("÷", "/"),
901                ("×", "x"),
902                ("−", "-"),
903            ] {
904                if s.contains(from) {
905                    s = s.replace(from, to);
906                }
907            }
908            s
909        }
910    }
911}
912
913fn sf_symbol_glyph(name: &str) -> &'static str {
914    let n = name.to_ascii_lowercase();
915    match n.as_str() {
916        "gear" | "gearshape" | "gearshape.fill" => "*",
917        "person" | "person.fill" => "☺",
918        "checkmark" | "checkmark.circle" | "checkmark.circle.fill" => "✓",
919        "calendar" | "calendar.badge.clock" | "calendar.circle" => "◷",
920        "xmark" | "xmark.circle" => "x",
921        "star" | "star.fill" => "*",
922        "heart" | "heart.fill" => "+",
923        "bell" | "bell.fill" => "!",
924        "house" | "house.fill" => "H",
925        "magnifyingglass" => "?",
926        "plus" | "plus.circle" => "+",
927        "minus" | "minus.circle" => "-",
928        _ => "•",
929    }
930}
931
932#[cfg(test)]
933mod tests {
934    use super::*;
935    use std::fs;
936    use std::path::PathBuf;
937
938    fn fixtures_dir() -> PathBuf {
939        PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures")
940    }
941
942    fn snapshots_dir() -> PathBuf {
943        PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/snapshots/adaptive")
944    }
945
946    fn load_config(rel: &str) -> WidgetConfig {
947        let path = fixtures_dir().join(rel);
948        let raw =
949            fs::read_to_string(&path).unwrap_or_else(|e| panic!("read {}: {e}", path.display()));
950        // Fixtures may contain explicit nulls — strip via Value first.
951        let v: Value = serde_json::from_str(&raw).expect("json");
952        serde_json::from_value(v).unwrap_or_else(|e| panic!("parse {rel}: {e}"))
953    }
954
955    #[test]
956    fn weather_small_is_valid_v15() {
957        let cfg = load_config("presets/weather.json");
958        let result = to_adaptive_card_for_size(&cfg, "small").expect("small layout");
959        validate_card_structure(&result.card).unwrap();
960        assert_eq!(result.card["body"].as_array().unwrap().len(), 1);
961        assert_eq!(result.card["body"][0]["type"], "Container");
962    }
963
964    #[test]
965    fn unsupported_nodes_are_skipped() {
966        let cfg: WidgetConfig = serde_json::from_value(json!({
967            "version": 1,
968            "small": {
969                "type": "vstack",
970                "children": [
971                    { "type": "text", "content": "hi" },
972                    { "type": "canvas", "width": 10, "height": 10, "elements": [] },
973                    { "type": "zstack", "children": [
974                        { "type": "text", "content": "a" }
975                    ] }
976                ]
977            }
978        }))
979        .unwrap();
980        let result = to_adaptive_card_for_size(&cfg, "small").unwrap();
981        // zstack flattens; canvas rasterizes
982        let body = serde_json::to_string(&result.card["body"]).unwrap();
983        assert!(body.contains("Container") || body.contains("Image") || body.contains("TextBlock"));
984        validate_card_structure(&result.card).unwrap();
985    }
986
987    #[test]
988    #[cfg(feature = "rasterize")]
989    fn chart_becomes_image_data_uri() {
990        let cfg: WidgetConfig = serde_json::from_value(json!({
991            "version": 1,
992            "small": {
993                "type": "chart",
994                "chartType": "bar",
995                "chartData": [
996                    { "label": "a", "value": 1 },
997                    { "label": "b", "value": 2 }
998                ]
999            }
1000        }))
1001        .unwrap();
1002        let result = to_adaptive_card_for_size(&cfg, "small").unwrap();
1003        assert!(result.skipped.is_empty(), "{:?}", result.skipped);
1004        assert_eq!(result.card["body"][0]["type"], "Image");
1005        let url = result.card["body"][0]["url"].as_str().unwrap();
1006        assert!(url.starts_with("data:image/png;base64,"));
1007    }
1008
1009    #[test]
1010    fn weather_small_matches_golden_or_writes_hint() {
1011        let cfg = load_config("presets/weather.json");
1012        let result = to_adaptive_card_for_size(&cfg, "small").unwrap();
1013        let golden_path = snapshots_dir().join("weather.small.json");
1014        if !golden_path.exists() {
1015            fs::create_dir_all(snapshots_dir()).unwrap();
1016            let pretty = serde_json::to_string_pretty(&result.card).unwrap();
1017            fs::write(&golden_path, pretty).unwrap();
1018            // First run creates golden — still assert structure.
1019            validate_card_structure(&result.card).unwrap();
1020            return;
1021        }
1022        let expected: Value =
1023            serde_json::from_str(&fs::read_to_string(&golden_path).unwrap()).unwrap();
1024        assert_eq!(
1025            result.card, expected,
1026            "adaptive card drift — update tests/snapshots/adaptive/weather.small.json if intentional"
1027        );
1028    }
1029
1030    #[test]
1031    fn cases_transpile_without_panic() {
1032        let cases_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/cases");
1033        let Ok(entries) = fs::read_dir(&cases_dir) else {
1034            return;
1035        };
1036        for entry in entries.flatten() {
1037            let path = entry.path();
1038            if path.extension().and_then(|e| e.to_str()) != Some("json") {
1039                continue;
1040            }
1041            let case: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
1042            let fixture = case["fixture"].as_str().unwrap_or("");
1043            let size = case["size"].as_str().unwrap_or("small");
1044            let cfg_path = fixtures_dir().join(format!("{fixture}.json"));
1045            if !cfg_path.exists() {
1046                continue;
1047            }
1048            let cfg = load_config(&format!("{fixture}.json"));
1049            let Some(result) = to_adaptive_card_for_size(&cfg, size) else {
1050                continue;
1051            };
1052            validate_card_structure(&result.card)
1053                .unwrap_or_else(|e| panic!("{}: {e}", path.display()));
1054        }
1055    }
1056}