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}