Skip to main content

dioxus_theme_core/
lib.rs

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