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