Skip to main content

native_theme/
presets.rs

1//! Bundled theme presets and TOML serialization API.
2//!
3//! Provides 16 user-facing built-in presets embedded at compile time:
4//! 6 platform (kde-breeze, adwaita, windows-11, macos-sonoma, material,
5//! ios) and 10 community (Catppuccin 4 flavors, Nord, Dracula, Gruvbox,
6//! Solarized, Tokyo Night, One Dark), plus 4 internal live presets
7//! (geometry-only, used by the OS-first pipeline) and functions for
8//! loading themes from TOML strings and files.
9
10use crate::{Error, Result, ThemeSpec};
11use std::collections::HashMap;
12use std::path::Path;
13use std::sync::LazyLock;
14
15/// All preset entries (name + embedded TOML source), parsed once into a HashMap.
16///
17/// To add a new preset, add it here and (if user-facing) to [`PRESET_NAMES`].
18const PRESET_ENTRIES: &[(&str, &str)] = &[
19    // Platform presets
20    ("kde-breeze", include_str!("presets/kde-breeze.toml")),
21    ("adwaita", include_str!("presets/adwaita.toml")),
22    ("windows-11", include_str!("presets/windows-11.toml")),
23    ("macos-sonoma", include_str!("presets/macos-sonoma.toml")),
24    ("material", include_str!("presets/material.toml")),
25    ("ios", include_str!("presets/ios.toml")),
26    // Community presets
27    (
28        "catppuccin-latte",
29        include_str!("presets/catppuccin-latte.toml"),
30    ),
31    (
32        "catppuccin-frappe",
33        include_str!("presets/catppuccin-frappe.toml"),
34    ),
35    (
36        "catppuccin-macchiato",
37        include_str!("presets/catppuccin-macchiato.toml"),
38    ),
39    (
40        "catppuccin-mocha",
41        include_str!("presets/catppuccin-mocha.toml"),
42    ),
43    ("nord", include_str!("presets/nord.toml")),
44    ("dracula", include_str!("presets/dracula.toml")),
45    ("gruvbox", include_str!("presets/gruvbox.toml")),
46    ("solarized", include_str!("presets/solarized.toml")),
47    ("tokyo-night", include_str!("presets/tokyo-night.toml")),
48    ("one-dark", include_str!("presets/one-dark.toml")),
49    // Internal live presets (geometry-only, not user-selectable)
50    (
51        "kde-breeze-live",
52        include_str!("presets/kde-breeze-live.toml"),
53    ),
54    ("adwaita-live", include_str!("presets/adwaita-live.toml")),
55    (
56        "macos-sonoma-live",
57        include_str!("presets/macos-sonoma-live.toml"),
58    ),
59    (
60        "windows-11-live",
61        include_str!("presets/windows-11-live.toml"),
62    ),
63];
64
65/// All available user-facing preset names (excludes internal live presets).
66const PRESET_NAMES: &[&str] = &[
67    "kde-breeze",
68    "adwaita",
69    "windows-11",
70    "macos-sonoma",
71    "material",
72    "ios",
73    "catppuccin-latte",
74    "catppuccin-frappe",
75    "catppuccin-macchiato",
76    "catppuccin-mocha",
77    "nord",
78    "dracula",
79    "gruvbox",
80    "solarized",
81    "tokyo-night",
82    "one-dark",
83];
84
85// Cached presets: each parsed at most once for the process lifetime.
86// Errors are stored as String because Error is not Clone, which is
87// required for LazyLock storage without lifetime constraints.
88type Parsed = std::result::Result<ThemeSpec, String>;
89
90fn parse(toml_str: &str) -> Parsed {
91    from_toml(toml_str).map_err(|e| e.to_string())
92}
93
94static CACHE: LazyLock<HashMap<&str, Parsed>> = LazyLock::new(|| {
95    PRESET_ENTRIES
96        .iter()
97        .map(|(name, toml_str)| (*name, parse(toml_str)))
98        .collect()
99});
100
101pub(crate) fn preset(name: &str) -> Result<ThemeSpec> {
102    match CACHE.get(name) {
103        None => Err(Error::Unavailable(format!("unknown preset: {name}"))),
104        Some(Ok(theme)) => Ok(theme.clone()),
105        Some(Err(msg)) => Err(Error::Format(format!("bundled preset '{name}': {msg}"))),
106    }
107}
108
109pub(crate) fn list_presets() -> &'static [&'static str] {
110    PRESET_NAMES
111}
112
113/// Platform-specific preset names that should only appear on their native platform.
114const PLATFORM_SPECIFIC: &[(&str, &[&str])] = &[
115    ("kde-breeze", &["linux-kde"]),
116    ("adwaita", &["linux"]),
117    ("windows-11", &["windows"]),
118    ("macos-sonoma", &["macos"]),
119    ("ios", &["macos", "ios"]),
120];
121
122/// Detect the current platform tag for preset filtering.
123///
124/// Returns a string like "linux-kde", "linux", "windows", or "macos".
125#[allow(unreachable_code)]
126fn detect_platform() -> &'static str {
127    #[cfg(target_os = "macos")]
128    {
129        return "macos";
130    }
131    #[cfg(target_os = "windows")]
132    {
133        return "windows";
134    }
135    #[cfg(target_os = "linux")]
136    {
137        if crate::detect_linux_de(&crate::xdg_current_desktop()) == crate::LinuxDesktop::Kde {
138            return "linux-kde";
139        }
140        "linux"
141    }
142    #[cfg(target_os = "ios")]
143    {
144        return "ios";
145    }
146    #[cfg(not(any(
147        target_os = "linux",
148        target_os = "windows",
149        target_os = "macos",
150        target_os = "ios"
151    )))]
152    {
153        "linux"
154    }
155}
156
157/// Returns preset names appropriate for the current platform.
158///
159/// Platform-specific presets (kde-breeze, adwaita, windows-11, macos-sonoma, ios)
160/// are only included on their native platform. Community themes are always included.
161pub(crate) fn list_presets_for_platform() -> Vec<&'static str> {
162    let platform = detect_platform();
163
164    PRESET_NAMES
165        .iter()
166        .filter(|name| {
167            if let Some((_, platforms)) = PLATFORM_SPECIFIC.iter().find(|(n, _)| n == *name) {
168                platforms.iter().any(|p| platform.starts_with(p))
169            } else {
170                true // Community themes always visible
171            }
172        })
173        .copied()
174        .collect()
175}
176
177pub(crate) fn from_toml(toml_str: &str) -> Result<ThemeSpec> {
178    let theme: ThemeSpec = toml::from_str(toml_str)?;
179    Ok(theme)
180}
181
182pub(crate) fn from_file(path: impl AsRef<Path>) -> Result<ThemeSpec> {
183    let contents = std::fs::read_to_string(path)?;
184    from_toml(&contents)
185}
186
187pub(crate) fn to_toml(theme: &ThemeSpec) -> Result<String> {
188    let s = toml::to_string_pretty(theme)?;
189    Ok(s)
190}
191
192#[cfg(test)]
193#[allow(clippy::unwrap_used, clippy::expect_used)]
194mod tests {
195    use super::*;
196
197    // NOTE: all_presets_loadable_via_preset_fn and all_presets_have_nonempty_core_colors
198    // are covered by tests/preset_loading.rs (all_presets_parse_without_error,
199    // all_presets_have_both_variants, all_presets_have_core_colors).
200
201    #[test]
202    fn preset_unknown_name_returns_unavailable() {
203        let err = preset("nonexistent").unwrap_err();
204        match err {
205            Error::Unavailable(msg) => assert!(msg.contains("nonexistent")),
206            other => panic!("expected Unavailable, got: {other:?}"),
207        }
208    }
209
210    // NOTE: list_presets_returns_all_sixteen is covered by
211    // tests/preset_loading.rs::list_presets_returns_sixteen_entries.
212
213    #[test]
214    fn from_toml_minimal_valid() {
215        let toml_str = r##"
216name = "Minimal"
217
218[light.defaults]
219accent_color = "#ff0000"
220"##;
221        let theme = from_toml(toml_str).unwrap();
222        assert_eq!(theme.name, "Minimal");
223        assert!(theme.light.is_some());
224        let light = theme.light.unwrap();
225        assert_eq!(
226            light.defaults.accent_color,
227            Some(crate::Rgba::rgb(255, 0, 0))
228        );
229    }
230
231    #[test]
232    fn from_toml_invalid_returns_format_error() {
233        let err = from_toml("{{{{invalid toml").unwrap_err();
234        match err {
235            Error::Format(_) => {}
236            other => panic!("expected Format, got: {other:?}"),
237        }
238    }
239
240    #[test]
241    fn to_toml_produces_valid_round_trip() {
242        let theme = preset("catppuccin-mocha").unwrap();
243        let toml_str = to_toml(&theme).unwrap();
244
245        // Must be parseable back into a ThemeSpec
246        let reparsed = from_toml(&toml_str).unwrap();
247        assert_eq!(reparsed.name, theme.name);
248        assert!(reparsed.light.is_some());
249        assert!(reparsed.dark.is_some());
250
251        // Core colors should survive the round-trip
252        let orig_light = theme.light.as_ref().unwrap();
253        let new_light = reparsed.light.as_ref().unwrap();
254        assert_eq!(
255            orig_light.defaults.accent_color,
256            new_light.defaults.accent_color
257        );
258    }
259
260    #[test]
261    fn from_file_with_tempfile() {
262        let dir = std::env::temp_dir();
263        let path = dir.join("native_theme_test_preset.toml");
264        let toml_str = r##"
265name = "File Test"
266
267[light.defaults]
268accent_color = "#00ff00"
269"##;
270        std::fs::write(&path, toml_str).unwrap();
271
272        let theme = from_file(&path).unwrap();
273        assert_eq!(theme.name, "File Test");
274        assert!(theme.light.is_some());
275
276        // Clean up
277        let _ = std::fs::remove_file(&path);
278    }
279
280    // === icon_set preset tests ===
281
282    #[test]
283    fn icon_set_native_presets_have_correct_values() {
284        use crate::IconSet;
285        let cases: &[(&str, IconSet)] = &[
286            ("windows-11", IconSet::SegoeIcons),
287            ("macos-sonoma", IconSet::SfSymbols),
288            ("ios", IconSet::SfSymbols),
289            ("adwaita", IconSet::Freedesktop),
290            ("kde-breeze", IconSet::Freedesktop),
291            ("material", IconSet::Material),
292        ];
293        for (name, expected) in cases {
294            let theme = preset(name).unwrap();
295            let light = theme.light.as_ref().unwrap();
296            assert_eq!(
297                light.icon_set,
298                Some(*expected),
299                "preset '{name}' light.icon_set should be Some({expected:?})"
300            );
301            let dark = theme.dark.as_ref().unwrap();
302            assert_eq!(
303                dark.icon_set,
304                Some(*expected),
305                "preset '{name}' dark.icon_set should be Some({expected:?})"
306            );
307        }
308    }
309
310    #[test]
311    fn icon_set_community_presets_have_lucide() {
312        let community = &[
313            "catppuccin-latte",
314            "catppuccin-frappe",
315            "catppuccin-macchiato",
316            "catppuccin-mocha",
317            "nord",
318            "dracula",
319            "gruvbox",
320            "solarized",
321            "tokyo-night",
322            "one-dark",
323        ];
324        for name in community {
325            let theme = preset(name).unwrap();
326            let light = theme.light.as_ref().unwrap();
327            assert_eq!(
328                light.icon_set,
329                Some(crate::IconSet::Lucide),
330                "preset '{name}' light.icon_set should be Lucide"
331            );
332            let dark = theme.dark.as_ref().unwrap();
333            assert_eq!(
334                dark.icon_set,
335                Some(crate::IconSet::Lucide),
336                "preset '{name}' dark.icon_set should be Lucide"
337            );
338        }
339    }
340
341    #[test]
342    fn icon_set_community_presets_resolve_to_platform_value() {
343        let community = &[
344            "catppuccin-latte",
345            "catppuccin-frappe",
346            "catppuccin-macchiato",
347            "catppuccin-mocha",
348            "nord",
349            "dracula",
350            "gruvbox",
351            "solarized",
352            "tokyo-night",
353            "one-dark",
354        ];
355        for name in community {
356            let theme = preset(name).unwrap();
357            let mut light = theme.light.clone().unwrap();
358            light.resolve_all();
359            assert!(
360                light.icon_set.is_some(),
361                "preset '{name}' light.icon_set should be Some after resolve_all()"
362            );
363            let mut dark = theme.dark.clone().unwrap();
364            dark.resolve_all();
365            assert!(
366                dark.icon_set.is_some(),
367                "preset '{name}' dark.icon_set should be Some after resolve_all()"
368            );
369        }
370    }
371
372    #[test]
373    fn from_file_nonexistent_returns_error() {
374        let err = from_file("/tmp/nonexistent_theme_file_12345.toml").unwrap_err();
375        match err {
376            Error::Io(e) => assert_eq!(e.kind(), std::io::ErrorKind::NotFound),
377            other => panic!("expected Io, got: {other:?}"),
378        }
379    }
380
381    #[test]
382    fn preset_names_match_list() {
383        // Every name in list_presets() must be loadable via preset()
384        for name in list_presets() {
385            assert!(preset(name).is_ok(), "preset '{name}' not loadable");
386        }
387    }
388
389    // NOTE: presets_have_correct_names is tested via tests/preset_loading.rs.
390    // NOTE: all_presets_with_fonts_have_valid_sizes is covered by
391    // tests/preset_loading.rs::all_presets_have_valid_fonts.
392
393    #[test]
394    fn platform_presets_no_derived_fields() {
395        // Platform presets must not contain fields that are derived by resolve()
396        let platform_presets = &["kde-breeze", "adwaita", "windows-11", "macos-sonoma"];
397        for name in platform_presets {
398            let theme = preset(name).unwrap();
399            for (label, variant_opt) in [
400                ("light", theme.light.as_ref()),
401                ("dark", theme.dark.as_ref()),
402            ] {
403                let variant = variant_opt.unwrap();
404                // button.primary_background is derived from accent - should not be in presets
405                assert!(
406                    variant.button.primary_background.is_none(),
407                    "preset '{name}' {label}.button.primary_background should be None (derived)"
408                );
409                // checkbox.checked_background is derived from accent
410                assert!(
411                    variant.checkbox.checked_background.is_none(),
412                    "preset '{name}' {label}.checkbox.checked_background should be None (derived)"
413                );
414                // slider.fill is derived from accent
415                assert!(
416                    variant.slider.fill_color.is_none(),
417                    "preset '{name}' {label}.slider.fill_color should be None (derived)"
418                );
419                // progress_bar.fill is derived from accent
420                assert!(
421                    variant.progress_bar.fill_color.is_none(),
422                    "preset '{name}' {label}.progress_bar.fill_color should be None (derived)"
423                );
424                // switch.checked_background is derived from accent
425                assert!(
426                    variant.switch.checked_background.is_none(),
427                    "preset '{name}' {label}.switch.checked_background should be None (derived)"
428                );
429            }
430        }
431    }
432
433    // === resolve()/validate() integration tests (PRESET-03) ===
434
435    #[test]
436    fn all_presets_resolve_validate() {
437        for name in list_presets() {
438            let theme = preset(name).unwrap();
439            if let Some(mut light) = theme.light.clone() {
440                light.resolve_all();
441                light.validate().unwrap_or_else(|e| {
442                    panic!("preset {name} light variant failed validation: {e}");
443                });
444            }
445            if let Some(mut dark) = theme.dark.clone() {
446                dark.resolve_all();
447                dark.validate().unwrap_or_else(|e| {
448                    panic!("preset {name} dark variant failed validation: {e}");
449                });
450            }
451        }
452    }
453
454    #[test]
455    fn resolve_fills_accent_derived_fields() {
456        // Load a preset that only has accent set (not explicit widget accent-derived fields).
457        // After resolve(), the accent-derived fields should be populated.
458        let theme = preset("catppuccin-mocha").unwrap();
459        let mut light = theme.light.clone().unwrap();
460
461        // Before resolve: accent-derived fields should be None (not in preset TOML)
462        assert!(
463            light.button.primary_background.is_none(),
464            "primary_background should be None pre-resolve"
465        );
466        assert!(
467            light.checkbox.checked_background.is_none(),
468            "checkbox.checked_background should be None pre-resolve"
469        );
470        assert!(
471            light.slider.fill_color.is_none(),
472            "slider.fill should be None pre-resolve"
473        );
474        assert!(
475            light.progress_bar.fill_color.is_none(),
476            "progress_bar.fill should be None pre-resolve"
477        );
478        assert!(
479            light.switch.checked_background.is_none(),
480            "switch.checked_background should be None pre-resolve"
481        );
482
483        light.resolve();
484
485        // After resolve: all accent-derived fields should equal accent
486        let accent = light.defaults.accent_color.unwrap();
487        assert_eq!(
488            light.button.primary_background,
489            Some(accent),
490            "button.primary_background should match accent"
491        );
492        assert_eq!(
493            light.checkbox.checked_background,
494            Some(accent),
495            "checkbox.checked_background should match accent"
496        );
497        assert_eq!(
498            light.slider.fill_color,
499            Some(accent),
500            "slider.fill should match accent"
501        );
502        assert_eq!(
503            light.progress_bar.fill_color,
504            Some(accent),
505            "progress_bar.fill should match accent"
506        );
507        assert_eq!(
508            light.switch.checked_background,
509            Some(accent),
510            "switch.checked_background should match accent"
511        );
512    }
513
514    #[test]
515    fn resolve_then_validate_produces_complete_theme() {
516        let theme = preset("catppuccin-mocha").unwrap();
517        let mut light = theme.light.clone().unwrap();
518        light.resolve_all();
519        let resolved = light.validate().unwrap();
520
521        assert_eq!(resolved.defaults.font.family, "Inter");
522        assert_eq!(resolved.defaults.font.size, 14.0);
523        assert_eq!(resolved.defaults.font.weight, 400);
524        assert_eq!(resolved.defaults.line_height, 1.2);
525        assert_eq!(resolved.defaults.border.corner_radius, 8.0);
526        assert_eq!(resolved.defaults.focus_ring_width, 2.0);
527        assert_eq!(resolved.defaults.icon_sizes.toolbar, 24.0);
528        assert_eq!(resolved.defaults.text_scaling_factor, 1.0);
529        assert!(!resolved.defaults.reduce_motion);
530        // Window inherits from defaults
531        assert_eq!(
532            resolved.window.background_color,
533            resolved.defaults.background_color
534        );
535        // icon_set should be populated (Lucide for community presets)
536        assert_eq!(resolved.icon_set, crate::IconSet::Lucide);
537    }
538
539    #[test]
540    fn font_subfield_inheritance_integration() {
541        // Load a preset, set menu.font to only have size=12.0 (clear family/weight),
542        // resolve, and verify family/weight are inherited from defaults.
543        let theme = preset("catppuccin-mocha").unwrap();
544        let mut light = theme.light.clone().unwrap();
545
546        // Set partial font on menu
547        use crate::model::FontSpec;
548        use crate::model::font::FontSize;
549        light.menu.font = Some(FontSpec {
550            family: None,
551            size: Some(FontSize::Px(12.0)),
552            weight: None,
553            ..Default::default()
554        });
555
556        light.resolve_all();
557        let resolved = light.validate().unwrap();
558
559        // menu font should have inherited family/weight from defaults
560        assert_eq!(
561            resolved.menu.font.family, "Inter",
562            "menu font family should inherit from defaults"
563        );
564        assert_eq!(
565            resolved.menu.font.size, 12.0,
566            "menu font size should be the explicit value"
567        );
568        assert_eq!(
569            resolved.menu.font.weight, 400,
570            "menu font weight should inherit from defaults"
571        );
572    }
573
574    #[test]
575    fn text_scale_inheritance_integration() {
576        // Load a preset, ensure text_scale.caption gets populated from defaults.
577        let theme = preset("catppuccin-mocha").unwrap();
578        let mut light = theme.light.clone().unwrap();
579
580        // Clear caption to test inheritance
581        light.text_scale.caption = None;
582
583        light.resolve_all();
584        let resolved = light.validate().unwrap();
585
586        // caption should have been populated from defaults.font (no ratio)
587        // defaults.font.size = 14.0, so caption.size = 14.0
588        let expected_size = 14.0;
589        assert!(
590            (resolved.text_scale.caption.size - expected_size).abs() < 0.01,
591            "caption size = defaults.font.size, got {}",
592            resolved.text_scale.caption.size
593        );
594        assert_eq!(
595            resolved.text_scale.caption.weight, 400,
596            "caption weight from defaults.font.weight"
597        );
598        // line_height = defaults.line_height * caption_size = 1.2 * 14.0 = 16.8
599        let expected_lh = 1.2 * expected_size;
600        assert!(
601            (resolved.text_scale.caption.line_height - expected_lh).abs() < 0.01,
602            "caption line_height should be line_height_multiplier * caption_size = {expected_lh}, got {}",
603            resolved.text_scale.caption.line_height
604        );
605    }
606
607    // NOTE: all_presets_round_trip_exact is covered by
608    // tests/preset_loading.rs::all_presets_round_trip_toml.
609
610    // === Live preset tests ===
611
612    #[test]
613    fn live_presets_loadable() {
614        let live_names = &[
615            "kde-breeze-live",
616            "adwaita-live",
617            "macos-sonoma-live",
618            "windows-11-live",
619        ];
620        for name in live_names {
621            let theme = preset(name)
622                .unwrap_or_else(|e| panic!("live preset '{name}' failed to parse: {e}"));
623
624            // Both variants must exist
625            assert!(
626                theme.light.is_some(),
627                "live preset '{name}' missing light variant"
628            );
629            assert!(
630                theme.dark.is_some(),
631                "live preset '{name}' missing dark variant"
632            );
633
634            let light = theme.light.as_ref().unwrap();
635            let dark = theme.dark.as_ref().unwrap();
636
637            // No colors
638            assert!(
639                light.defaults.accent_color.is_none(),
640                "live preset '{name}' light should have no accent"
641            );
642            assert!(
643                light.defaults.background_color.is_none(),
644                "live preset '{name}' light should have no background"
645            );
646            assert!(
647                light.defaults.text_color.is_none(),
648                "live preset '{name}' light should have no foreground"
649            );
650            assert!(
651                dark.defaults.accent_color.is_none(),
652                "live preset '{name}' dark should have no accent"
653            );
654            assert!(
655                dark.defaults.background_color.is_none(),
656                "live preset '{name}' dark should have no background"
657            );
658            assert!(
659                dark.defaults.text_color.is_none(),
660                "live preset '{name}' dark should have no foreground"
661            );
662
663            // No fonts
664            assert!(
665                light.defaults.font.family.is_none(),
666                "live preset '{name}' light should have no font family"
667            );
668            assert!(
669                light.defaults.font.size.is_none(),
670                "live preset '{name}' light should have no font size"
671            );
672            assert!(
673                light.defaults.font.weight.is_none(),
674                "live preset '{name}' light should have no font weight"
675            );
676            assert!(
677                dark.defaults.font.family.is_none(),
678                "live preset '{name}' dark should have no font family"
679            );
680            assert!(
681                dark.defaults.font.size.is_none(),
682                "live preset '{name}' dark should have no font size"
683            );
684            assert!(
685                dark.defaults.font.weight.is_none(),
686                "live preset '{name}' dark should have no font weight"
687            );
688        }
689    }
690
691    #[test]
692    fn list_presets_for_platform_returns_subset() {
693        let all = list_presets();
694        let filtered = list_presets_for_platform();
695        // Filtered list should be a subset of all presets
696        for name in &filtered {
697            assert!(
698                all.contains(name),
699                "filtered preset '{name}' not in full list"
700            );
701        }
702        // Community themes should always be present
703        let community = &[
704            "catppuccin-latte",
705            "catppuccin-frappe",
706            "catppuccin-macchiato",
707            "catppuccin-mocha",
708            "nord",
709            "dracula",
710            "gruvbox",
711            "solarized",
712            "tokyo-night",
713            "one-dark",
714        ];
715        for name in community {
716            assert!(
717                filtered.contains(name),
718                "community preset '{name}' should always be in filtered list"
719            );
720        }
721        // material is cross-platform, always present
722        assert!(
723            filtered.contains(&"material"),
724            "material should always be in filtered list"
725        );
726    }
727
728    #[test]
729    fn live_presets_fail_validate_standalone() {
730        let live_names = &[
731            "kde-breeze-live",
732            "adwaita-live",
733            "macos-sonoma-live",
734            "windows-11-live",
735        ];
736        for name in live_names {
737            let theme = preset(name).unwrap();
738            let mut light = theme.light.clone().unwrap();
739            light.resolve();
740            let result = light.validate();
741            assert!(
742                result.is_err(),
743                "live preset '{name}' light should fail validation standalone (missing colors/fonts)"
744            );
745
746            let mut dark = theme.dark.clone().unwrap();
747            dark.resolve();
748            let result = dark.validate();
749            assert!(
750                result.is_err(),
751                "live preset '{name}' dark should fail validation standalone (missing colors/fonts)"
752            );
753        }
754    }
755}