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