1use std::borrow::Cow;
12use std::collections::HashMap;
13use std::path::Path;
14use std::sync::LazyLock;
15
16use crate::{Error, Result, Theme};
17
18const PRESET_ENTRIES: &[(&str, &str)] = &[
22 ("kde-breeze", include_str!("presets/kde-breeze.toml")),
24 ("adwaita", include_str!("presets/adwaita.toml")),
25 ("windows-11", include_str!("presets/windows-11.toml")),
26 ("macos-sonoma", include_str!("presets/macos-sonoma.toml")),
27 ("material", include_str!("presets/material.toml")),
28 ("ios", include_str!("presets/ios.toml")),
29 (
31 "catppuccin-latte",
32 include_str!("presets/catppuccin-latte.toml"),
33 ),
34 (
35 "catppuccin-frappe",
36 include_str!("presets/catppuccin-frappe.toml"),
37 ),
38 (
39 "catppuccin-macchiato",
40 include_str!("presets/catppuccin-macchiato.toml"),
41 ),
42 (
43 "catppuccin-mocha",
44 include_str!("presets/catppuccin-mocha.toml"),
45 ),
46 ("nord", include_str!("presets/nord.toml")),
47 ("dracula", include_str!("presets/dracula.toml")),
48 ("gruvbox", include_str!("presets/gruvbox.toml")),
49 ("solarized", include_str!("presets/solarized.toml")),
50 ("tokyo-night", include_str!("presets/tokyo-night.toml")),
51 ("one-dark", include_str!("presets/one-dark.toml")),
52 (
54 "kde-breeze-live",
55 include_str!("presets/kde-breeze-live.toml"),
56 ),
57 ("adwaita-live", include_str!("presets/adwaita-live.toml")),
58 (
59 "macos-sonoma-live",
60 include_str!("presets/macos-sonoma-live.toml"),
61 ),
62 (
63 "windows-11-live",
64 include_str!("presets/windows-11-live.toml"),
65 ),
66];
67
68const PRESET_NAMES: &[&str] = &[
70 "kde-breeze",
71 "adwaita",
72 "windows-11",
73 "macos-sonoma",
74 "material",
75 "ios",
76 "catppuccin-latte",
77 "catppuccin-frappe",
78 "catppuccin-macchiato",
79 "catppuccin-mocha",
80 "nord",
81 "dracula",
82 "gruvbox",
83 "solarized",
84 "tokyo-night",
85 "one-dark",
86];
87
88const PRESET_DISPLAY_NAMES: &[(&str, &str)] = &[
95 ("kde-breeze", "KDE Breeze"),
96 ("kde-breeze-live", "KDE Breeze"),
97 ("adwaita", "Adwaita"),
98 ("adwaita-live", "Adwaita"),
99 ("windows-11", "Windows 11"),
100 ("windows-11-live", "Windows 11"),
101 ("macos-sonoma", "macOS Sonoma"),
102 ("macos-sonoma-live", "macOS Sonoma"),
103 ("material", "Material"),
104 ("ios", "iOS"),
105 ("catppuccin-latte", "Catppuccin Latte"),
106 ("catppuccin-frappe", "Catppuccin Frappe"),
107 ("catppuccin-macchiato", "Catppuccin Macchiato"),
108 ("catppuccin-mocha", "Catppuccin Mocha"),
109 ("nord", "Nord"),
110 ("dracula", "Dracula"),
111 ("gruvbox", "Gruvbox"),
112 ("solarized", "Solarized"),
113 ("tokyo-night", "Tokyo Night"),
114 ("one-dark", "One Dark"),
115];
116
117#[derive(Clone, Debug, PartialEq, Eq)]
122pub struct PresetInfo {
123 pub key: &'static str,
126 pub display_name: &'static str,
128 pub platforms: &'static [&'static str],
131 pub light_only: bool,
133}
134
135const PRESET_INFOS: &[PresetInfo] = &[
140 PresetInfo {
142 key: "kde-breeze",
143 display_name: "KDE Breeze",
144 platforms: &["linux-kde"],
145 light_only: false,
146 },
147 PresetInfo {
148 key: "adwaita",
149 display_name: "Adwaita",
150 platforms: &["linux"],
151 light_only: false,
152 },
153 PresetInfo {
154 key: "windows-11",
155 display_name: "Windows 11",
156 platforms: &["windows"],
157 light_only: false,
158 },
159 PresetInfo {
160 key: "macos-sonoma",
161 display_name: "macOS Sonoma",
162 platforms: &["macos"],
163 light_only: false,
164 },
165 PresetInfo {
166 key: "material",
167 display_name: "Material",
168 platforms: &[],
169 light_only: false,
170 },
171 PresetInfo {
172 key: "ios",
173 display_name: "iOS",
174 platforms: &["macos", "ios"],
175 light_only: false,
176 },
177 PresetInfo {
179 key: "catppuccin-latte",
180 display_name: "Catppuccin Latte",
181 platforms: &[],
182 light_only: false,
183 },
184 PresetInfo {
185 key: "catppuccin-frappe",
186 display_name: "Catppuccin Frappe",
187 platforms: &[],
188 light_only: false,
189 },
190 PresetInfo {
191 key: "catppuccin-macchiato",
192 display_name: "Catppuccin Macchiato",
193 platforms: &[],
194 light_only: false,
195 },
196 PresetInfo {
197 key: "catppuccin-mocha",
198 display_name: "Catppuccin Mocha",
199 platforms: &[],
200 light_only: false,
201 },
202 PresetInfo {
203 key: "nord",
204 display_name: "Nord",
205 platforms: &[],
206 light_only: false,
207 },
208 PresetInfo {
209 key: "dracula",
210 display_name: "Dracula",
211 platforms: &[],
212 light_only: false,
213 },
214 PresetInfo {
215 key: "gruvbox",
216 display_name: "Gruvbox",
217 platforms: &[],
218 light_only: false,
219 },
220 PresetInfo {
221 key: "solarized",
222 display_name: "Solarized",
223 platforms: &[],
224 light_only: false,
225 },
226 PresetInfo {
227 key: "tokyo-night",
228 display_name: "Tokyo Night",
229 platforms: &[],
230 light_only: false,
231 },
232 PresetInfo {
233 key: "one-dark",
234 display_name: "One Dark",
235 platforms: &[],
236 light_only: false,
237 },
238];
239
240type Parsed = std::result::Result<Theme, String>;
245
246fn parse(toml_str: &str) -> Parsed {
247 from_toml(toml_str).map_err(|e| e.to_string())
248}
249
250static CACHE: LazyLock<HashMap<&str, Parsed>> = LazyLock::new(|| {
251 PRESET_ENTRIES
252 .iter()
253 .map(|(key, toml_str)| {
254 let mut result = parse(toml_str);
255 if let Ok(ref mut theme) = result
257 && let Some((_, display_name)) = PRESET_DISPLAY_NAMES.iter().find(|(k, _)| k == key)
258 {
259 theme.name = Cow::Borrowed(display_name);
260 }
261 (*key, result)
262 })
263 .collect()
264});
265
266pub(crate) fn preset(name: &str) -> Result<Theme> {
267 match CACHE.get(name) {
268 None => Err(Error::UnknownPreset {
269 name: name.to_string(),
270 known: PRESET_NAMES,
271 }),
272 Some(Ok(theme)) => Ok(theme.clone()),
273 Some(Err(msg)) => Err(Error::ReaderFailed {
274 reader: "preset_cache",
275 source: format!("bundled preset '{name}': {msg}").into(),
276 }),
277 }
278}
279
280pub(crate) fn list_presets() -> &'static [PresetInfo] {
281 PRESET_INFOS
282}
283
284#[allow(unreachable_code)]
288fn detect_platform() -> &'static str {
289 #[cfg(target_os = "macos")]
290 {
291 return "macos";
292 }
293 #[cfg(target_os = "windows")]
294 {
295 return "windows";
296 }
297 #[cfg(target_os = "linux")]
298 {
299 if crate::detect::detect_linux_desktop() == crate::detect::LinuxDesktop::Kde {
300 return "linux-kde";
301 }
302 "linux"
303 }
304 #[cfg(target_os = "ios")]
305 {
306 return "ios";
307 }
308 #[cfg(not(any(
309 target_os = "linux",
310 target_os = "windows",
311 target_os = "macos",
312 target_os = "ios"
313 )))]
314 {
315 "linux"
316 }
317}
318
319pub(crate) fn list_presets_for_platform() -> Vec<PresetInfo> {
324 let platform = detect_platform();
325
326 PRESET_INFOS
327 .iter()
328 .filter(|info| {
329 info.platforms.is_empty() || info.platforms.iter().any(|p| platform.starts_with(p))
330 })
331 .cloned()
332 .collect()
333}
334
335pub(crate) fn from_toml(toml_str: &str) -> Result<Theme> {
336 let theme: Theme = toml::from_str(toml_str)?;
337 Ok(theme)
338}
339
340pub(crate) fn from_file(path: impl AsRef<Path>) -> Result<Theme> {
341 let contents = std::fs::read_to_string(path)?;
342 from_toml(&contents)
343}
344
345pub(crate) fn to_toml(theme: &Theme) -> Result<String> {
346 let s = toml::to_string_pretty(theme)?;
347 Ok(s)
348}
349
350#[cfg(test)]
351mod documented_sizes;
352
353#[cfg(test)]
354#[allow(clippy::unwrap_used, clippy::expect_used)]
355mod tests {
356 use super::*;
357
358 #[test]
366 fn every_bundled_preset_source_lints_clean() {
367 for (name, source) in PRESET_ENTRIES {
368 let warnings = crate::Theme::lint_toml(source)
369 .unwrap_or_else(|e| panic!("preset {name} should lint: {e}"));
370 assert!(
371 warnings.is_empty(),
372 "preset {name}'s source has lint warnings: {warnings:?}"
373 );
374 }
375 assert!(
376 PRESET_ENTRIES
377 .iter()
378 .any(|(_, s)| s.contains("padding_horizontal_px")),
379 "no bundled preset uses the shorthand any more, so this test no \
380 longer proves the linter accepts it"
381 );
382 }
383
384 #[test]
385 fn preset_unknown_name_returns_unknown_preset() {
386 let err = preset("nonexistent").unwrap_err();
387 let Error::UnknownPreset { name, .. } = err else {
388 return;
389 };
390 assert!(name.contains("nonexistent"));
391 }
392
393 #[test]
397 fn from_toml_minimal_valid() {
398 let toml_str = r##"
399name = "Minimal"
400
401[light.defaults]
402accent_color = "#ff0000"
403"##;
404 let theme = from_toml(toml_str).unwrap();
405 assert_eq!(theme.name, "Minimal");
406 assert!(theme.light.is_some());
407 let light = theme.light.unwrap();
408 assert_eq!(
409 light.defaults.accent_color,
410 Some(crate::Rgba::rgb(255, 0, 0))
411 );
412 }
413
414 #[test]
415 fn from_toml_invalid_returns_toml_error() {
416 let err = from_toml("{{{{invalid toml").unwrap_err();
417 assert!(
418 matches!(err, Error::Toml(_)),
419 "expected Toml variant, got: {err:?}"
420 );
421 }
422
423 #[test]
424 fn to_toml_produces_valid_round_trip() {
425 let theme = preset("catppuccin-mocha").unwrap();
426 let toml_str = to_toml(&theme).unwrap();
427
428 let reparsed = from_toml(&toml_str).unwrap();
430 assert_eq!(reparsed.name, theme.name);
431 assert!(reparsed.light.is_some());
432 assert!(reparsed.dark.is_some());
433
434 let orig_light = theme.light.as_ref().unwrap();
436 let new_light = reparsed.light.as_ref().unwrap();
437 assert_eq!(
438 orig_light.defaults.accent_color,
439 new_light.defaults.accent_color
440 );
441 }
442
443 #[test]
444 fn from_file_with_tempfile() {
445 let dir = std::env::temp_dir();
446 let path = dir.join("native_theme_test_preset.toml");
447 let toml_str = r##"
448name = "File Test"
449
450[light.defaults]
451accent_color = "#00ff00"
452"##;
453 std::fs::write(&path, toml_str).unwrap();
454
455 let theme = from_file(&path).unwrap();
456 assert_eq!(theme.name, "File Test");
457 assert!(theme.light.is_some());
458
459 let _ = std::fs::remove_file(&path);
461 }
462
463 #[test]
466 fn icon_set_native_presets_have_correct_values() {
467 use crate::IconSet;
468 let cases: &[(&str, IconSet)] = &[
469 ("windows-11", IconSet::SegoeIcons),
470 ("macos-sonoma", IconSet::SfSymbols),
471 ("ios", IconSet::SfSymbols),
472 ("adwaita", IconSet::Freedesktop),
473 ("kde-breeze", IconSet::Freedesktop),
474 ("material", IconSet::Material),
475 ];
476 for (name, expected) in cases {
477 let theme = preset(name).unwrap();
478 assert_eq!(
479 theme.icon_set,
480 Some(*expected),
481 "preset '{name}' icon_set should be Some({expected:?})"
482 );
483 }
484 }
485
486 #[test]
487 fn icon_set_community_presets_have_lucide() {
488 let community = &[
489 "catppuccin-latte",
490 "catppuccin-frappe",
491 "catppuccin-macchiato",
492 "catppuccin-mocha",
493 "nord",
494 "dracula",
495 "gruvbox",
496 "solarized",
497 "tokyo-night",
498 "one-dark",
499 ];
500 for name in community {
501 let theme = preset(name).unwrap();
502 assert_eq!(
503 theme.icon_set,
504 Some(crate::IconSet::Lucide),
505 "preset '{name}' icon_set should be Lucide"
506 );
507 }
508 }
509
510 #[test]
511 fn from_file_nonexistent_returns_error() {
512 let err = from_file("/tmp/nonexistent_theme_file_12345.toml").unwrap_err();
513 match err {
514 Error::Io(e) => assert_eq!(e.kind(), std::io::ErrorKind::NotFound),
515 other => panic!("expected Io, got: {other:?}"),
516 }
517 }
518
519 #[test]
520 fn preset_names_match_list() {
521 for info in list_presets() {
523 assert!(
524 preset(info.key).is_ok(),
525 "preset '{}' not loadable",
526 info.key
527 );
528 }
529 }
530
531 #[test]
536 fn platform_presets_no_derived_fields() {
537 let platform_presets = &["kde-breeze", "adwaita", "windows-11", "macos-sonoma"];
539 for name in platform_presets {
540 let theme = preset(name).unwrap();
541 for (label, variant_opt) in [
542 ("light", theme.light.as_ref()),
543 ("dark", theme.dark.as_ref()),
544 ] {
545 let variant = variant_opt.unwrap();
546 assert!(
548 variant.button.primary_background.is_none(),
549 "preset '{name}' {label}.button.primary_background should be None (derived)"
550 );
551 assert_eq!(
555 variant.checkbox.checked_background.is_some(),
556 *name == "kde-breeze",
557 "preset '{name}' {label}.checkbox.checked_background: stated only where \
558 platform-facts does not derive it from the accent"
559 );
560 assert!(
562 variant.slider.fill_color.is_none(),
563 "preset '{name}' {label}.slider.fill_color should be None (derived)"
564 );
565 assert_eq!(
569 variant.progress_bar.fill_color.is_some(),
570 *name == "kde-breeze",
571 "preset '{name}' {label}.progress_bar.fill_color: stated only where \
572 platform-facts does not derive it from the accent"
573 );
574 assert!(
576 variant.switch.checked_background.is_none(),
577 "preset '{name}' {label}.switch.checked_background should be None (derived)"
578 );
579 }
580 }
581 }
582
583 #[test]
586 fn all_presets_resolve_validate() {
587 for info in list_presets() {
588 let name = info.key;
589 let theme = preset(name).unwrap();
590 if let Some(mut light) = theme.light.clone() {
591 light.resolve_all();
592 light.validate().unwrap_or_else(|e| {
593 panic!("preset {name} light variant failed validation: {e}");
594 });
595 }
596 if let Some(mut dark) = theme.dark.clone() {
597 dark.resolve_all();
598 dark.validate().unwrap_or_else(|e| {
599 panic!("preset {name} dark variant failed validation: {e}");
600 });
601 }
602 }
603 }
604
605 #[test]
606 fn all_presets_indicator_color_inherits_accent_text_color() {
607 for info in list_presets() {
611 let name = info.key;
612 let theme = preset(name).unwrap();
613 for (label, variant_opt) in
614 [("light", theme.light.clone()), ("dark", theme.dark.clone())]
615 {
616 let Some(mut variant) = variant_opt else {
617 continue;
618 };
619 if variant.checkbox.indicator_color.is_some() {
620 continue;
621 }
622 variant.resolve_all();
623 assert_eq!(
624 variant.checkbox.indicator_color, variant.defaults.accent_text_color,
625 "preset '{name}' {label}.checkbox.indicator_color should inherit \
626 defaults.accent_text_color"
627 );
628 }
629 }
630 }
631
632 #[test]
633 fn resolve_fills_accent_derived_fields() {
634 let theme = preset("catppuccin-mocha").unwrap();
637 let mut light = theme.light.clone().unwrap();
638
639 assert!(
641 light.button.primary_background.is_none(),
642 "primary_background should be None pre-resolve"
643 );
644 assert!(
645 light.checkbox.checked_background.is_none(),
646 "checkbox.checked_background should be None pre-resolve"
647 );
648 assert!(
649 light.slider.fill_color.is_none(),
650 "slider.fill should be None pre-resolve"
651 );
652 assert!(
653 light.progress_bar.fill_color.is_none(),
654 "progress_bar.fill should be None pre-resolve"
655 );
656 assert!(
657 light.switch.checked_background.is_none(),
658 "switch.checked_background should be None pre-resolve"
659 );
660
661 light.resolve();
662
663 let accent = light.defaults.accent_color.unwrap();
665 assert_eq!(
666 light.button.primary_background,
667 Some(accent),
668 "button.primary_background should match accent"
669 );
670 assert_eq!(
671 light.checkbox.checked_background,
672 Some(accent),
673 "checkbox.checked_background should match accent"
674 );
675 assert_eq!(
676 light.slider.fill_color,
677 Some(accent),
678 "slider.fill should match accent"
679 );
680 assert_eq!(
681 light.progress_bar.fill_color,
682 Some(accent),
683 "progress_bar.fill should match accent"
684 );
685 assert_eq!(
686 light.switch.checked_background,
687 Some(accent),
688 "switch.checked_background should match accent"
689 );
690 }
691
692 #[test]
693 fn resolve_then_validate_produces_complete_theme() {
694 let theme = preset("catppuccin-mocha").unwrap();
695 let mut light = theme.light.clone().unwrap();
696 light.resolve_all();
697 let resolved = light.validate().unwrap();
698
699 assert_eq!(resolved.defaults.font.family.as_ref(), "Inter");
700 assert_eq!(resolved.defaults.font.size, 14.0);
701 assert_eq!(resolved.defaults.font.weight, 400);
702 assert_eq!(resolved.defaults.line_height, 1.2);
703 assert_eq!(resolved.defaults.border.corner_radius, 8.0);
704 assert_eq!(resolved.defaults.focus_ring_width, 2.0);
705 assert_eq!(resolved.defaults.icon_sizes.toolbar, 24.0);
706 assert_eq!(resolved.defaults.icon_sizes.toolbar, 24.0);
707 assert_eq!(
709 resolved.window.background_color,
710 resolved.defaults.background_color
711 );
712 assert_eq!(theme.icon_set, Some(crate::IconSet::Lucide));
714 }
715
716 #[test]
717 fn font_subfield_inheritance_integration() {
718 let theme = preset("catppuccin-mocha").unwrap();
721 let mut light = theme.light.clone().unwrap();
722
723 use crate::model::FontSpec;
725 use crate::model::font::FontSize;
726 light.menu.font = Some(FontSpec {
727 family: None,
728 size: Some(FontSize::Px(12.0)),
729 weight: None,
730 ..Default::default()
731 });
732
733 light.resolve_all();
734 let resolved = light.validate().unwrap();
735
736 assert_eq!(
738 resolved.menu.font.family.as_ref(),
739 "Inter",
740 "menu font family should inherit from defaults"
741 );
742 assert_eq!(
743 resolved.menu.font.size, 12.0,
744 "menu font size should be the explicit value"
745 );
746 assert_eq!(
747 resolved.menu.font.weight, 400,
748 "menu font weight should inherit from defaults"
749 );
750 }
751
752 #[test]
753 fn text_scale_inheritance_integration() {
754 let theme = preset("catppuccin-mocha").unwrap();
756 let mut light = theme.light.clone().unwrap();
757
758 light.text_scale.caption = None;
760
761 light.resolve_all();
762 let resolved = light.validate().unwrap();
763
764 let expected_size = 14.0;
767 assert!(
768 (resolved.text_scale.caption.size - expected_size).abs() < 0.01,
769 "caption size = defaults.font.size, got {}",
770 resolved.text_scale.caption.size
771 );
772 assert_eq!(
773 resolved.text_scale.caption.weight, 400,
774 "caption weight from defaults.font.weight"
775 );
776 let expected_lh = 1.2 * expected_size;
778 assert!(
779 (resolved.text_scale.caption.line_height - expected_lh).abs() < 0.01,
780 "caption line_height should be line_height_multiplier * caption_size = {expected_lh}, got {}",
781 resolved.text_scale.caption.line_height
782 );
783 }
784
785 #[test]
791 fn live_presets_loadable() {
792 let live_names = &[
793 "kde-breeze-live",
794 "adwaita-live",
795 "macos-sonoma-live",
796 "windows-11-live",
797 ];
798 for name in live_names {
799 let theme = preset(name)
800 .unwrap_or_else(|e| panic!("live preset '{name}' failed to parse: {e}"));
801
802 assert!(
804 theme.light.is_some(),
805 "live preset '{name}' missing light variant"
806 );
807 assert!(
808 theme.dark.is_some(),
809 "live preset '{name}' missing dark variant"
810 );
811
812 let light = theme.light.as_ref().unwrap();
813 let dark = theme.dark.as_ref().unwrap();
814
815 assert!(
817 light.defaults.accent_color.is_none(),
818 "live preset '{name}' light should have no accent"
819 );
820 assert!(
821 light.defaults.background_color.is_none(),
822 "live preset '{name}' light should have no background"
823 );
824 assert!(
825 light.defaults.text_color.is_none(),
826 "live preset '{name}' light should have no foreground"
827 );
828 assert!(
829 dark.defaults.accent_color.is_none(),
830 "live preset '{name}' dark should have no accent"
831 );
832 assert!(
833 dark.defaults.background_color.is_none(),
834 "live preset '{name}' dark should have no background"
835 );
836 assert!(
837 dark.defaults.text_color.is_none(),
838 "live preset '{name}' dark should have no foreground"
839 );
840
841 assert!(
843 light.defaults.font.family.is_none(),
844 "live preset '{name}' light should have no font family"
845 );
846 assert!(
847 light.defaults.font.size.is_none(),
848 "live preset '{name}' light should have no font size"
849 );
850 assert!(
851 light.defaults.font.weight.is_none(),
852 "live preset '{name}' light should have no font weight"
853 );
854 assert!(
855 dark.defaults.font.family.is_none(),
856 "live preset '{name}' dark should have no font family"
857 );
858 assert!(
859 dark.defaults.font.size.is_none(),
860 "live preset '{name}' dark should have no font size"
861 );
862 assert!(
863 dark.defaults.font.weight.is_none(),
864 "live preset '{name}' dark should have no font weight"
865 );
866 }
867 }
868
869 #[test]
870 fn list_presets_for_platform_returns_subset() {
871 let all = list_presets();
872 let filtered = list_presets_for_platform();
873 for info in &filtered {
875 assert!(
876 all.iter().any(|a| a.key == info.key),
877 "filtered preset '{}' not in full list",
878 info.key
879 );
880 }
881 let community = &[
883 "catppuccin-latte",
884 "catppuccin-frappe",
885 "catppuccin-macchiato",
886 "catppuccin-mocha",
887 "nord",
888 "dracula",
889 "gruvbox",
890 "solarized",
891 "tokyo-night",
892 "one-dark",
893 ];
894 for name in community {
895 assert!(
896 filtered.iter().any(|info| info.key == *name),
897 "community preset '{name}' should always be in filtered list"
898 );
899 }
900 assert!(
902 filtered.iter().any(|info| info.key == "material"),
903 "material should always be in filtered list"
904 );
905 }
906
907 #[test]
908 fn live_presets_fail_validate_standalone() {
909 let live_names = &[
910 "kde-breeze-live",
911 "adwaita-live",
912 "macos-sonoma-live",
913 "windows-11-live",
914 ];
915 for name in live_names {
916 let theme = preset(name).unwrap();
917 let mut light = theme.light.clone().unwrap();
918 light.resolve();
919 let result = light.validate();
920 assert!(
921 result.is_err(),
922 "live preset '{name}' light should fail validation standalone (missing colors/fonts)"
923 );
924
925 let mut dark = theme.dark.clone().unwrap();
926 dark.resolve();
927 let result = dark.validate();
928 assert!(
929 result.is_err(),
930 "live preset '{name}' dark should fail validation standalone (missing colors/fonts)"
931 );
932 }
933 }
934}