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