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}