Skip to main content

dioxus_theme_core/
lib.rs

1use std::collections::BTreeMap;
2
3use serde::{Deserialize, Serialize};
4
5pub const DEFAULT_THEME_RUNTIME_BASE_PATH: &str = "/assets/dioxus-theme.js";
6pub const DEFAULT_THEME_RUNTIME_VERSION: &str = "1";
7pub const DEFAULT_THEME_RUNTIME_PATH: &str = "/assets/dioxus-theme.js?v=1";
8pub const DEFAULT_THEME_STORAGE_KEY: &str = "dioxus-theme";
9pub const DEFAULT_THEME_ATTRIBUTE: &str = "data-dxt-theme";
10pub const DEFAULT_THEME_TARGET: &str = "html";
11pub const DEFAULT_THEME_DURATION_MS: u32 = 220;
12pub const DEFAULT_THEME_EASING: &str = "ease-in-out";
13pub const DEFAULT_THEME_ANIMATION_STORAGE_KEY: &str = "dioxus-theme-animation";
14pub const DEFAULT_THEME_ANIMATION_SPEED: u16 = 100;
15pub const DEFAULT_THEME_ANIMATION_SPEED_STORAGE_KEY: &str = "dioxus-theme-animation-speed";
16pub const MIN_THEME_ANIMATION_SPEED: u16 = 25;
17pub const MAX_THEME_ANIMATION_SPEED: u16 = 300;
18pub const THEME_TOKEN_BG: &str = "--dxt-bg";
19pub const THEME_TOKEN_FG: &str = "--dxt-fg";
20pub const THEME_TOKEN_MUTED: &str = "--dxt-muted";
21pub const THEME_TOKEN_PANEL: &str = "--dxt-panel";
22pub const THEME_TOKEN_PANEL_BORDER: &str = "--dxt-panel-border";
23pub const THEME_TOKEN_ACCENT: &str = "--dxt-accent";
24pub const THEME_TOKEN_BACKGROUND: &str = THEME_TOKEN_BG;
25pub const THEME_TOKEN_TEXT: &str = THEME_TOKEN_FG;
26pub const THEME_TOKEN_SURFACE: &str = THEME_TOKEN_PANEL;
27pub const THEME_TOKEN_SURFACE_BORDER: &str = THEME_TOKEN_PANEL_BORDER;
28pub const THEME_CHANGE_EVENT: &str = "dioxus-theme:change";
29pub const THEME_VISUAL_TOKEN_MANIFEST_VERSION: u8 = 1;
30
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
32#[serde(rename_all = "kebab-case")]
33pub enum ThemeVisualTokenRole {
34    Background,
35    Text,
36    Muted,
37    Surface,
38    SurfaceBorder,
39    Accent,
40}
41
42impl ThemeVisualTokenRole {
43    pub const fn as_attr(self) -> &'static str {
44        match self {
45            Self::Background => "background",
46            Self::Text => "text",
47            Self::Muted => "muted",
48            Self::Surface => "surface",
49            Self::SurfaceBorder => "surface-border",
50            Self::Accent => "accent",
51        }
52    }
53
54    pub const fn js_key(self) -> &'static str {
55        match self {
56            Self::Background => "background",
57            Self::Text => "text",
58            Self::Muted => "muted",
59            Self::Surface => "surface",
60            Self::SurfaceBorder => "surfaceBorder",
61            Self::Accent => "accent",
62        }
63    }
64
65    pub const fn css_var(self) -> &'static str {
66        match self {
67            Self::Background => THEME_TOKEN_BACKGROUND,
68            Self::Text => THEME_TOKEN_TEXT,
69            Self::Muted => THEME_TOKEN_MUTED,
70            Self::Surface => THEME_TOKEN_SURFACE,
71            Self::SurfaceBorder => THEME_TOKEN_SURFACE_BORDER,
72            Self::Accent => THEME_TOKEN_ACCENT,
73        }
74    }
75}
76
77#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
78#[serde(rename_all = "camelCase")]
79pub struct ThemeVisualTokenDefinition {
80    pub role: ThemeVisualTokenRole,
81    pub key: &'static str,
82    pub css_var: &'static str,
83}
84
85#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
86#[serde(rename_all = "camelCase")]
87pub struct ThemeVisualTokenManifest {
88    pub version: u8,
89    pub change_event: &'static str,
90    pub tokens: &'static [ThemeVisualTokenDefinition],
91}
92
93pub const THEME_VISUAL_TOKENS: [ThemeVisualTokenDefinition; 6] = [
94    ThemeVisualTokenDefinition {
95        role: ThemeVisualTokenRole::Background,
96        key: ThemeVisualTokenRole::Background.js_key(),
97        css_var: THEME_TOKEN_BACKGROUND,
98    },
99    ThemeVisualTokenDefinition {
100        role: ThemeVisualTokenRole::Text,
101        key: ThemeVisualTokenRole::Text.js_key(),
102        css_var: THEME_TOKEN_TEXT,
103    },
104    ThemeVisualTokenDefinition {
105        role: ThemeVisualTokenRole::Muted,
106        key: ThemeVisualTokenRole::Muted.js_key(),
107        css_var: THEME_TOKEN_MUTED,
108    },
109    ThemeVisualTokenDefinition {
110        role: ThemeVisualTokenRole::Surface,
111        key: ThemeVisualTokenRole::Surface.js_key(),
112        css_var: THEME_TOKEN_SURFACE,
113    },
114    ThemeVisualTokenDefinition {
115        role: ThemeVisualTokenRole::SurfaceBorder,
116        key: ThemeVisualTokenRole::SurfaceBorder.js_key(),
117        css_var: THEME_TOKEN_SURFACE_BORDER,
118    },
119    ThemeVisualTokenDefinition {
120        role: ThemeVisualTokenRole::Accent,
121        key: ThemeVisualTokenRole::Accent.js_key(),
122        css_var: THEME_TOKEN_ACCENT,
123    },
124];
125
126pub const fn theme_visual_token_manifest() -> ThemeVisualTokenManifest {
127    ThemeVisualTokenManifest {
128        version: THEME_VISUAL_TOKEN_MANIFEST_VERSION,
129        change_event: THEME_CHANGE_EVENT,
130        tokens: &THEME_VISUAL_TOKENS,
131    }
132}
133
134pub fn theme_visual_token_manifest_json() -> Result<String, serde_json::Error> {
135    serde_json::to_string(&theme_visual_token_manifest())
136}
137
138#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
139#[serde(rename_all = "kebab-case")]
140pub enum ThemeColorScheme {
141    Light,
142    Dark,
143    System,
144    Normal,
145}
146
147impl Default for ThemeColorScheme {
148    fn default() -> Self {
149        Self::System
150    }
151}
152
153impl ThemeColorScheme {
154    pub fn as_css(self) -> &'static str {
155        match self {
156            Self::Light => "light",
157            Self::Dark => "dark",
158            Self::System => "light dark",
159            Self::Normal => "normal",
160        }
161    }
162}
163
164#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
165#[serde(rename_all = "kebab-case")]
166pub enum ThemeAnimationMode {
167    ViewTransition,
168    CssOnly,
169    None,
170}
171
172impl Default for ThemeAnimationMode {
173    fn default() -> Self {
174        Self::ViewTransition
175    }
176}
177
178impl ThemeAnimationMode {
179    pub fn as_attr(self) -> &'static str {
180        match self {
181            Self::ViewTransition => "view-transition",
182            Self::CssOnly => "css-only",
183            Self::None => "none",
184        }
185    }
186
187    pub fn is_animated(self) -> bool {
188        !matches!(self, Self::None)
189    }
190}
191
192#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
193#[serde(rename_all = "kebab-case")]
194pub enum ThemeAnimationPreset {
195    Fade,
196    CrossFade,
197    Slide,
198    RadialWipe,
199    MaskedWave,
200}
201
202impl Default for ThemeAnimationPreset {
203    fn default() -> Self {
204        Self::CrossFade
205    }
206}
207
208impl ThemeAnimationPreset {
209    pub const fn all() -> &'static [Self; 5] {
210        &[
211            Self::Fade,
212            Self::CrossFade,
213            Self::Slide,
214            Self::RadialWipe,
215            Self::MaskedWave,
216        ]
217    }
218
219    pub const fn as_attr(self) -> &'static str {
220        match self {
221            Self::Fade => "fade",
222            Self::CrossFade => "cross-fade",
223            Self::Slide => "slide",
224            Self::RadialWipe => "radial-wipe",
225            Self::MaskedWave => "masked-wave",
226        }
227    }
228
229    pub const fn label(self) -> &'static str {
230        match self {
231            Self::Fade => "Fade",
232            Self::CrossFade => "Cross fade",
233            Self::Slide => "Slide",
234            Self::RadialWipe => "Radial wipe",
235            Self::MaskedWave => "Masked wave",
236        }
237    }
238}
239
240#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
241#[serde(rename_all = "kebab-case")]
242pub enum ThemeReducedMotion {
243    Respect,
244    Ignore,
245}
246
247#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
248#[serde(rename_all = "kebab-case")]
249pub enum ThemeValidationSeverity {
250    Error,
251    Warning,
252}
253
254#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
255#[serde(rename_all = "kebab-case")]
256pub enum ThemeValidationCode {
257    EmptyStorageKey,
258    EmptyAnimationStorageKey,
259    EmptyAnimationSpeedStorageKey,
260    MissingDefaultTheme,
261    MissingSystemLightTheme,
262    MissingSystemDarkTheme,
263    InvalidTarget,
264    InvalidAttribute,
265    InvalidTokenName,
266    UnsafeTokenValue,
267}
268
269#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
270#[serde(rename_all = "camelCase")]
271pub struct ThemeValidationIssue {
272    pub severity: ThemeValidationSeverity,
273    pub code: ThemeValidationCode,
274    pub message: String,
275    #[serde(default, skip_serializing_if = "Option::is_none")]
276    pub field: Option<String>,
277    #[serde(default, skip_serializing_if = "Option::is_none")]
278    pub theme: Option<String>,
279}
280
281impl ThemeValidationIssue {
282    pub fn error(
283        code: ThemeValidationCode,
284        field: impl Into<String>,
285        message: impl Into<String>,
286    ) -> Self {
287        Self {
288            severity: ThemeValidationSeverity::Error,
289            code,
290            message: message.into(),
291            field: Some(field.into()),
292            theme: None,
293        }
294    }
295
296    pub fn token_error(
297        code: ThemeValidationCode,
298        theme: impl Into<String>,
299        field: impl Into<String>,
300        message: impl Into<String>,
301    ) -> Self {
302        Self {
303            severity: ThemeValidationSeverity::Error,
304            code,
305            message: message.into(),
306            field: Some(field.into()),
307            theme: Some(theme.into()),
308        }
309    }
310}
311
312#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
313#[serde(rename_all = "camelCase")]
314pub struct ThemeValidationReport {
315    pub issues: Vec<ThemeValidationIssue>,
316}
317
318impl ThemeValidationReport {
319    pub fn is_valid(&self) -> bool {
320        self.issues
321            .iter()
322            .all(|issue| issue.severity != ThemeValidationSeverity::Error)
323    }
324
325    pub fn errors(&self) -> impl Iterator<Item = &ThemeValidationIssue> {
326        self.issues
327            .iter()
328            .filter(|issue| issue.severity == ThemeValidationSeverity::Error)
329    }
330
331    pub fn warnings(&self) -> impl Iterator<Item = &ThemeValidationIssue> {
332        self.issues
333            .iter()
334            .filter(|issue| issue.severity == ThemeValidationSeverity::Warning)
335    }
336
337    pub fn push(&mut self, issue: ThemeValidationIssue) {
338        self.issues.push(issue);
339    }
340}
341
342impl Default for ThemeReducedMotion {
343    fn default() -> Self {
344        Self::Respect
345    }
346}
347
348impl ThemeReducedMotion {
349    pub fn as_attr(self) -> &'static str {
350        match self {
351            Self::Respect => "respect",
352            Self::Ignore => "ignore",
353        }
354    }
355}
356
357#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
358#[serde(rename_all = "camelCase")]
359pub struct ThemeDefinition {
360    pub id: String,
361    pub label: String,
362    #[serde(default)]
363    pub color_scheme: ThemeColorScheme,
364    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
365    pub tokens: BTreeMap<String, String>,
366}
367
368impl ThemeDefinition {
369    pub fn new(id: impl AsRef<str>, label: impl Into<String>) -> Self {
370        Self {
371            id: theme_id(id),
372            label: label.into(),
373            color_scheme: ThemeColorScheme::System,
374            tokens: BTreeMap::new(),
375        }
376    }
377
378    pub fn light() -> Self {
379        Self::new("light", "Light")
380            .with_color_scheme(ThemeColorScheme::Light)
381            .with_token(THEME_TOKEN_BG, "#f8fafc")
382            .with_token(THEME_TOKEN_FG, "#0f172a")
383            .with_token(THEME_TOKEN_MUTED, "#475569")
384            .with_token(THEME_TOKEN_PANEL, "#ffffff")
385            .with_token(THEME_TOKEN_PANEL_BORDER, "rgba(15,23,42,0.12)")
386            .with_token(THEME_TOKEN_ACCENT, "#0891b2")
387    }
388
389    pub fn dark() -> Self {
390        Self::new("dark", "Dark")
391            .with_color_scheme(ThemeColorScheme::Dark)
392            .with_token(THEME_TOKEN_BG, "#020617")
393            .with_token(THEME_TOKEN_FG, "#f8fafc")
394            .with_token(THEME_TOKEN_MUTED, "#cbd5e1")
395            .with_token(THEME_TOKEN_PANEL, "rgba(15,23,42,0.74)")
396            .with_token(THEME_TOKEN_PANEL_BORDER, "rgba(255,255,255,0.10)")
397            .with_token(THEME_TOKEN_ACCENT, "#22d3ee")
398    }
399
400    pub fn system() -> Self {
401        Self::new("system", "System").with_color_scheme(ThemeColorScheme::System)
402    }
403
404    pub fn with_label(mut self, label: impl Into<String>) -> Self {
405        self.label = label.into();
406        self
407    }
408
409    pub fn with_color_scheme(mut self, color_scheme: ThemeColorScheme) -> Self {
410        self.color_scheme = color_scheme;
411        self
412    }
413
414    pub fn with_token(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
415        let name = name.into();
416        if is_custom_property_name(&name) {
417            self.tokens.insert(name, value.into());
418        }
419        self
420    }
421
422    pub fn with_tokens<I, K, V>(mut self, tokens: I) -> Self
423    where
424        I: IntoIterator<Item = (K, V)>,
425        K: Into<String>,
426        V: Into<String>,
427    {
428        for (name, value) in tokens {
429            let name = name.into();
430            if is_custom_property_name(&name) {
431                self.tokens.insert(name, value.into());
432            }
433        }
434        self
435    }
436
437    pub fn is_system(&self) -> bool {
438        self.id == "system"
439    }
440}
441
442#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
443#[serde(rename_all = "camelCase")]
444pub struct ThemeRegistry {
445    pub themes: Vec<ThemeDefinition>,
446}
447
448impl Default for ThemeRegistry {
449    fn default() -> Self {
450        Self::defaults()
451    }
452}
453
454impl ThemeRegistry {
455    pub fn new() -> Self {
456        Self { themes: Vec::new() }
457    }
458
459    pub fn defaults() -> Self {
460        Self::new()
461            .with_theme(ThemeDefinition::light())
462            .with_theme(ThemeDefinition::dark())
463            .with_theme(ThemeDefinition::system())
464    }
465
466    pub fn with_theme(mut self, theme: ThemeDefinition) -> Self {
467        self.insert_theme(theme);
468        self
469    }
470
471    pub fn insert_theme(&mut self, theme: ThemeDefinition) -> Option<ThemeDefinition> {
472        if let Some(existing) = self
473            .themes
474            .iter_mut()
475            .find(|candidate| candidate.id == theme.id)
476        {
477            return Some(std::mem::replace(existing, theme));
478        }
479        self.themes.push(theme);
480        None
481    }
482
483    pub fn contains_theme(&self, id: impl AsRef<str>) -> bool {
484        let id = theme_id(id);
485        self.themes.iter().any(|theme| theme.id == id)
486    }
487
488    pub fn theme(&self, id: impl AsRef<str>) -> Option<&ThemeDefinition> {
489        let id = theme_id(id);
490        self.themes.iter().find(|theme| theme.id == id)
491    }
492
493    pub fn theme_ids(&self) -> Vec<&str> {
494        self.themes.iter().map(|theme| theme.id.as_str()).collect()
495    }
496
497    pub fn first_non_system_theme(&self) -> Option<&ThemeDefinition> {
498        self.themes.iter().find(|theme| !theme.is_system())
499    }
500}
501
502#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
503#[serde(rename_all = "camelCase")]
504pub struct ThemeConfig {
505    pub registry: ThemeRegistry,
506    pub default_theme: String,
507    pub system_light_theme: String,
508    pub system_dark_theme: String,
509    pub storage_key: String,
510    pub attribute: String,
511    pub target: String,
512    pub duration_ms: u32,
513    pub easing: String,
514    pub reduced_motion: ThemeReducedMotion,
515    pub animation: ThemeAnimationMode,
516    pub animation_preset: ThemeAnimationPreset,
517    pub animation_storage_key: String,
518    pub animation_speed: u16,
519    pub animation_speed_storage_key: String,
520    pub isolate_view_transition_names: bool,
521    pub runtime_path: String,
522}
523
524impl Default for ThemeConfig {
525    fn default() -> Self {
526        Self::new()
527    }
528}
529
530impl ThemeConfig {
531    pub fn new() -> Self {
532        Self {
533            registry: ThemeRegistry::default(),
534            default_theme: "system".to_string(),
535            system_light_theme: "light".to_string(),
536            system_dark_theme: "dark".to_string(),
537            storage_key: DEFAULT_THEME_STORAGE_KEY.to_string(),
538            attribute: DEFAULT_THEME_ATTRIBUTE.to_string(),
539            target: DEFAULT_THEME_TARGET.to_string(),
540            duration_ms: DEFAULT_THEME_DURATION_MS,
541            easing: DEFAULT_THEME_EASING.to_string(),
542            reduced_motion: ThemeReducedMotion::Respect,
543            animation: ThemeAnimationMode::ViewTransition,
544            animation_preset: ThemeAnimationPreset::CrossFade,
545            animation_storage_key: DEFAULT_THEME_ANIMATION_STORAGE_KEY.to_string(),
546            animation_speed: DEFAULT_THEME_ANIMATION_SPEED,
547            animation_speed_storage_key: DEFAULT_THEME_ANIMATION_SPEED_STORAGE_KEY.to_string(),
548            isolate_view_transition_names: true,
549            runtime_path: DEFAULT_THEME_RUNTIME_PATH.to_string(),
550        }
551    }
552
553    pub fn with_registry(mut self, registry: ThemeRegistry) -> Self {
554        self.registry = registry;
555        self
556    }
557
558    pub fn with_theme(mut self, theme: ThemeDefinition) -> Self {
559        self.registry.insert_theme(theme);
560        self
561    }
562
563    pub fn with_default_theme(mut self, theme: impl AsRef<str>) -> Self {
564        self.default_theme = theme_id(theme);
565        self
566    }
567
568    pub fn with_system_theme(
569        mut self,
570        light_theme: impl AsRef<str>,
571        dark_theme: impl AsRef<str>,
572    ) -> Self {
573        self.system_light_theme = theme_id(light_theme);
574        self.system_dark_theme = theme_id(dark_theme);
575        self
576    }
577
578    pub fn with_storage_key(mut self, storage_key: impl Into<String>) -> Self {
579        self.storage_key = storage_key.into();
580        self
581    }
582
583    pub fn with_attribute(mut self, attribute: impl Into<String>) -> Self {
584        self.attribute = attribute.into();
585        self
586    }
587
588    pub fn with_target(mut self, target: impl Into<String>) -> Self {
589        self.target = target.into();
590        self
591    }
592
593    pub fn with_duration_ms(mut self, duration_ms: u32) -> Self {
594        self.duration_ms = duration_ms;
595        self
596    }
597
598    pub fn with_easing(mut self, easing: impl Into<String>) -> Self {
599        self.easing = easing.into();
600        self
601    }
602
603    pub fn with_reduced_motion(mut self, reduced_motion: ThemeReducedMotion) -> Self {
604        self.reduced_motion = reduced_motion;
605        self
606    }
607
608    #[cfg(feature = "viewtx")]
609    pub fn with_viewtx_timing(mut self, config: &dioxus_viewtx_core::ViewTransitionConfig) -> Self {
610        self.duration_ms = config.duration_ms;
611        self.easing = config.easing.clone();
612        self.reduced_motion = match config.reduced_motion {
613            dioxus_viewtx_core::ViewTransitionReducedMotion::Ignore => ThemeReducedMotion::Ignore,
614            dioxus_viewtx_core::ViewTransitionReducedMotion::Disable
615            | dioxus_viewtx_core::ViewTransitionReducedMotion::FadeOnly => {
616                ThemeReducedMotion::Respect
617            }
618        };
619        self
620    }
621
622    #[cfg(feature = "viewtx")]
623    pub fn with_viewtx_motion_policy(
624        mut self,
625        policy: &dioxus_viewtx_core::ViewMotionPolicy,
626    ) -> Self {
627        self.duration_ms = policy.duration_ms;
628        self.easing = policy.easing.clone();
629        self.reduced_motion = match policy.reduced_motion {
630            dioxus_viewtx_core::ViewTransitionReducedMotion::Ignore => ThemeReducedMotion::Ignore,
631            dioxus_viewtx_core::ViewTransitionReducedMotion::Disable
632            | dioxus_viewtx_core::ViewTransitionReducedMotion::FadeOnly => {
633                ThemeReducedMotion::Respect
634            }
635        };
636        self.isolate_view_transition_names = policy.isolate_view_transition_names();
637        self
638    }
639
640    pub fn with_animation(mut self, animation: ThemeAnimationMode) -> Self {
641        self.animation = animation;
642        self
643    }
644
645    pub fn with_animation_preset(mut self, animation_preset: ThemeAnimationPreset) -> Self {
646        self.animation_preset = animation_preset;
647        self
648    }
649
650    pub fn with_animation_storage_key(mut self, animation_storage_key: impl Into<String>) -> Self {
651        self.animation_storage_key = animation_storage_key.into();
652        self
653    }
654
655    pub fn with_animation_speed(mut self, animation_speed: u16) -> Self {
656        self.animation_speed = normalize_animation_speed(animation_speed);
657        self
658    }
659
660    pub fn with_animation_speed_storage_key(
661        mut self,
662        animation_speed_storage_key: impl Into<String>,
663    ) -> Self {
664        self.animation_speed_storage_key = animation_speed_storage_key.into();
665        self
666    }
667
668    pub fn with_view_transition_name_isolation(mut self, isolate: bool) -> Self {
669        self.isolate_view_transition_names = isolate;
670        self
671    }
672
673    pub fn with_runtime_path(mut self, runtime_path: impl Into<String>) -> Self {
674        self.runtime_path = runtime_path.into();
675        self
676    }
677
678    pub fn validate(&self) -> ThemeValidationReport {
679        let mut report = ThemeValidationReport::default();
680        if self.storage_key.trim().is_empty() {
681            report.push(ThemeValidationIssue::error(
682                ThemeValidationCode::EmptyStorageKey,
683                "storage_key",
684                "theme storage key must not be empty",
685            ));
686        }
687        if self.animation_storage_key.trim().is_empty() {
688            report.push(ThemeValidationIssue::error(
689                ThemeValidationCode::EmptyAnimationStorageKey,
690                "animation_storage_key",
691                "animation preset storage key must not be empty",
692            ));
693        }
694        if self.animation_speed_storage_key.trim().is_empty() {
695            report.push(ThemeValidationIssue::error(
696                ThemeValidationCode::EmptyAnimationSpeedStorageKey,
697                "animation_speed_storage_key",
698                "animation speed storage key must not be empty",
699            ));
700        }
701        if !self.registry.contains_theme(&self.default_theme) {
702            report.push(ThemeValidationIssue::error(
703                ThemeValidationCode::MissingDefaultTheme,
704                "default_theme",
705                format!("default theme `{}` is not registered", self.default_theme),
706            ));
707        }
708        if !self.registry.contains_theme(&self.system_light_theme) {
709            report.push(ThemeValidationIssue::error(
710                ThemeValidationCode::MissingSystemLightTheme,
711                "system_light_theme",
712                format!(
713                    "system light theme `{}` is not registered",
714                    self.system_light_theme
715                ),
716            ));
717        }
718        if !self.registry.contains_theme(&self.system_dark_theme) {
719            report.push(ThemeValidationIssue::error(
720                ThemeValidationCode::MissingSystemDarkTheme,
721                "system_dark_theme",
722                format!(
723                    "system dark theme `{}` is not registered",
724                    self.system_dark_theme
725                ),
726            ));
727        }
728        if !is_valid_theme_target(&self.target) {
729            report.push(ThemeValidationIssue::error(
730                ThemeValidationCode::InvalidTarget,
731                "target",
732                "theme target must be html, :root, or a simple selector",
733            ));
734        }
735        if !is_valid_theme_attribute(&self.attribute) {
736            report.push(ThemeValidationIssue::error(
737                ThemeValidationCode::InvalidAttribute,
738                "attribute",
739                "theme attribute must be a non-empty attribute name",
740            ));
741        }
742        for theme in &self.registry.themes {
743            for (name, value) in &theme.tokens {
744                if !is_custom_property_name(name) {
745                    report.push(ThemeValidationIssue::token_error(
746                        ThemeValidationCode::InvalidTokenName,
747                        theme.id.clone(),
748                        name.clone(),
749                        "theme token names must be CSS custom properties",
750                    ));
751                }
752                if !is_safe_css_token_value(value) {
753                    report.push(ThemeValidationIssue::token_error(
754                        ThemeValidationCode::UnsafeTokenValue,
755                        theme.id.clone(),
756                        name.clone(),
757                        "theme token values must be non-empty safe CSS values",
758                    ));
759                }
760            }
761        }
762        report
763    }
764
765    pub fn resolve_theme(&self, id: impl AsRef<str>) -> Option<&ThemeDefinition> {
766        let id = theme_id(id);
767        self.registry
768            .theme(&id)
769            .or_else(|| self.registry.theme(&self.default_theme))
770            .or_else(|| self.registry.first_non_system_theme())
771    }
772
773    pub fn toggle_theme_id(&self, current: impl AsRef<str>) -> String {
774        let current = theme_id(current);
775        let default = if self.default_theme == "system" {
776            self.system_dark_theme.as_str()
777        } else {
778            self.default_theme.as_str()
779        };
780        if current == default {
781            self.registry
782                .themes
783                .iter()
784                .find(|theme| !theme.is_system() && theme.id != default)
785                .map(|theme| theme.id.clone())
786                .unwrap_or_else(|| default.to_string())
787        } else {
788            default.to_string()
789        }
790    }
791
792    pub fn to_json(&self) -> Result<String, serde_json::Error> {
793        serde_json::to_string(self)
794    }
795}
796
797pub fn theme_id(id: impl AsRef<str>) -> String {
798    let mut output = String::new();
799    for ch in id.as_ref().chars() {
800        if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' {
801            output.push(ch.to_ascii_lowercase());
802        } else if ch.is_whitespace() || matches!(ch, '.' | ':' | '/') {
803            output.push('-');
804        }
805    }
806    let output = output.trim_matches('-');
807    if output.is_empty() {
808        "theme".to_string()
809    } else {
810        output.to_string()
811    }
812}
813
814pub fn is_custom_property_name(name: &str) -> bool {
815    let Some(rest) = name.strip_prefix("--") else {
816        return false;
817    };
818    !rest.is_empty()
819        && rest
820            .chars()
821            .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_'))
822}
823
824pub fn is_valid_theme_target(target: &str) -> bool {
825    let trimmed = target.trim();
826    matches!(trimmed, "html" | ":root")
827        || (!trimmed.is_empty()
828            && trimmed
829                .chars()
830                .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.' | '#')))
831}
832
833pub fn is_valid_theme_attribute(attribute: &str) -> bool {
834    let trimmed = attribute.trim();
835    !trimmed.is_empty()
836        && trimmed
837            .chars()
838            .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | ':'))
839}
840
841pub fn normalize_animation_speed(speed: u16) -> u16 {
842    speed.clamp(MIN_THEME_ANIMATION_SPEED, MAX_THEME_ANIMATION_SPEED)
843}
844
845pub fn theme_tokens_css(theme: &ThemeDefinition) -> String {
846    let mut css = String::new();
847    css.push_str("color-scheme:");
848    css.push_str(theme.color_scheme.as_css());
849    css.push(';');
850    for (name, value) in &theme.tokens {
851        if is_custom_property_name(name) && is_safe_css_token_value(value) {
852            css.push_str(name);
853            css.push(':');
854            css.push_str(value);
855            css.push(';');
856        }
857    }
858    css
859}
860
861pub fn is_safe_css_token_value(value: &str) -> bool {
862    !value.trim().is_empty()
863        && !value
864            .chars()
865            .any(|ch| ch.is_control() || matches!(ch, ';' | '{' | '}' | '<' | '>' | '`'))
866}
867
868#[cfg(test)]
869mod tests {
870    use super::*;
871
872    #[test]
873    fn registry_defaults_include_light_dark_system() {
874        let registry = ThemeRegistry::default();
875        assert!(registry.contains_theme("light"));
876        assert!(registry.contains_theme("dark"));
877        assert!(registry.contains_theme("system"));
878    }
879
880    #[test]
881    fn theme_ids_are_sanitized() {
882        assert_eq!(theme_id("High Contrast"), "high-contrast");
883        assert_eq!(theme_id(""), "theme");
884        assert_eq!(theme_id("../Dark Mode"), "dark-mode");
885    }
886
887    #[test]
888    fn duplicate_theme_replaces_existing_definition() {
889        let registry = ThemeRegistry::new()
890            .with_theme(ThemeDefinition::new("brand", "Brand"))
891            .with_theme(ThemeDefinition::new("brand", "Updated"));
892        assert_eq!(registry.themes.len(), 1);
893        assert_eq!(registry.theme("brand").unwrap().label, "Updated");
894    }
895
896    #[test]
897    fn token_css_contains_valid_custom_properties() {
898        let theme = ThemeDefinition::new("brand", "Brand")
899            .with_color_scheme(ThemeColorScheme::Dark)
900            .with_token("--brand-bg", "#000")
901            .with_token("--bad-value", "red;}body{display:none")
902            .with_token("bad", "#fff");
903        let css = theme_tokens_css(&theme);
904        assert!(css.contains("color-scheme:dark;"));
905        assert!(css.contains("--brand-bg:#000;"));
906        assert!(!css.contains("--bad-value"));
907        assert!(!css.contains("bad:#fff"));
908    }
909
910    #[test]
911    fn visual_token_manifest_is_stable_and_serializable() {
912        let manifest = theme_visual_token_manifest();
913        assert_eq!(manifest.version, THEME_VISUAL_TOKEN_MANIFEST_VERSION);
914        assert_eq!(manifest.change_event, THEME_CHANGE_EVENT);
915        assert_eq!(manifest.tokens.len(), 6);
916        assert_eq!(ThemeVisualTokenRole::Accent.css_var(), THEME_TOKEN_ACCENT);
917        assert_eq!(ThemeVisualTokenRole::Surface.js_key(), "surface");
918        assert_eq!(THEME_TOKEN_TEXT, THEME_TOKEN_FG);
919        assert_eq!(THEME_TOKEN_SURFACE, THEME_TOKEN_PANEL);
920
921        let json = theme_visual_token_manifest_json().expect("manifest serializes");
922        assert!(json.contains("\"changeEvent\":\"dioxus-theme:change\""));
923        assert!(json.contains("\"key\":\"surfaceBorder\""));
924        assert!(json.contains("\"cssVar\":\"--dxt-accent\""));
925    }
926
927    #[test]
928    fn config_serializes_camel_case_overrides() {
929        let json = ThemeConfig::default()
930            .with_storage_key("custom-theme")
931            .with_animation_storage_key("custom-animation")
932            .with_animation_preset(ThemeAnimationPreset::MaskedWave)
933            .with_animation_speed(175)
934            .with_animation_speed_storage_key("custom-animation-speed")
935            .with_view_transition_name_isolation(false)
936            .with_easing("linear")
937            .with_default_theme("dark")
938            .with_duration_ms(120)
939            .to_json()
940            .expect("config serializes");
941        assert!(json.contains("\"storageKey\":\"custom-theme\""));
942        assert!(json.contains("\"animationStorageKey\":\"custom-animation\""));
943        assert!(json.contains("\"animationPreset\":\"masked-wave\""));
944        assert!(json.contains("\"animationSpeed\":175"));
945        assert!(json.contains("\"animationSpeedStorageKey\":\"custom-animation-speed\""));
946        assert!(json.contains("\"isolateViewTransitionNames\":false"));
947        assert!(json.contains("\"easing\":\"linear\""));
948        assert!(json.contains("\"defaultTheme\":\"dark\""));
949        assert!(json.contains("\"durationMs\":120"));
950    }
951
952    #[test]
953    fn view_transition_name_isolation_defaults_on() {
954        let config = ThemeConfig::default();
955        assert!(config.isolate_view_transition_names);
956        assert!(
957            config
958                .to_json()
959                .expect("config serializes")
960                .contains("\"isolateViewTransitionNames\":true")
961        );
962    }
963
964    #[test]
965    fn animation_presets_are_stable_and_kebab_case() {
966        assert_eq!(
967            ThemeAnimationPreset::default(),
968            ThemeAnimationPreset::CrossFade
969        );
970        assert_eq!(ThemeAnimationPreset::all().len(), 5);
971        assert_eq!(ThemeAnimationPreset::MaskedWave.as_attr(), "masked-wave");
972        let json =
973            serde_json::to_string(&ThemeAnimationPreset::RadialWipe).expect("preset serializes");
974        assert_eq!(json, "\"radial-wipe\"");
975    }
976
977    #[test]
978    fn animation_speed_is_clamped() {
979        assert_eq!(
980            ThemeConfig::default()
981                .with_animation_speed(0)
982                .animation_speed,
983            MIN_THEME_ANIMATION_SPEED
984        );
985        assert_eq!(
986            ThemeConfig::default()
987                .with_animation_speed(500)
988                .animation_speed,
989            MAX_THEME_ANIMATION_SPEED
990        );
991    }
992
993    #[test]
994    fn validation_accepts_defaults_and_reports_bad_overrides() {
995        assert!(ThemeConfig::default().validate().is_valid());
996
997        let mut invalid = ThemeConfig::default()
998            .with_default_theme("missing")
999            .with_storage_key("")
1000            .with_animation_storage_key("")
1001            .with_animation_speed_storage_key("")
1002            .with_target("html body")
1003            .with_attribute("");
1004        invalid.registry.themes[0]
1005            .tokens
1006            .insert("bad".to_string(), "red".to_string());
1007        invalid.registry.themes[0]
1008            .tokens
1009            .insert("--unsafe".to_string(), "red;}body{display:none".to_string());
1010
1011        let report = invalid.validate();
1012        assert!(!report.is_valid());
1013        assert!(report.errors().count() >= 7);
1014        assert!(
1015            report
1016                .issues
1017                .iter()
1018                .any(|issue| issue.code == ThemeValidationCode::MissingDefaultTheme)
1019        );
1020        assert!(
1021            report
1022                .issues
1023                .iter()
1024                .any(|issue| issue.code == ThemeValidationCode::UnsafeTokenValue)
1025        );
1026    }
1027}