1use std::{collections::BTreeMap, fmt, sync::OnceLock};
2
3use serde::{Deserialize, Serialize};
4use std::time::Duration;
5
6mod integration;
7pub use integration::*;
8
9pub const DEFAULT_THEME_RUNTIME_BASE_PATH: &str = "/assets/dioxus-theme.js";
10pub const DEFAULT_THEME_RUNTIME_VERSION: &str = "1";
11pub const DEFAULT_THEME_RUNTIME_PATH: &str = "/assets/dioxus-theme.js?v=1";
12pub const THEME_PACKAGE_NAME: &str = "dioxus-theme";
13pub const THEME_PACKAGE_VERSION: &str = env!("CARGO_PKG_VERSION");
14pub const DEFAULT_THEME_STORAGE_KEY: &str = "dioxus-theme";
15pub const DEFAULT_THEME_ATTRIBUTE: &str = "data-dxt-theme";
16pub const DEFAULT_THEME_TARGET: &str = "html";
17pub const DEFAULT_THEME_DURATION_MS: u32 = 220;
18pub const DEFAULT_THEME_EASING: &str = "ease-in-out";
19pub const DEFAULT_THEME_ANIMATION_STORAGE_KEY: &str = "dioxus-theme-animation";
20pub const DEFAULT_THEME_ANIMATION_SPEED: u16 = 100;
21pub const DEFAULT_THEME_ANIMATION_SPEED_STORAGE_KEY: &str = "dioxus-theme-animation-speed";
22pub const MIN_THEME_ANIMATION_SPEED: u16 = 25;
23pub const MAX_THEME_ANIMATION_SPEED: u16 = 300;
24pub const THEME_TOKEN_BG: &str = "--dxt-bg";
25pub const THEME_TOKEN_FG: &str = "--dxt-fg";
26pub const THEME_TOKEN_MUTED: &str = "--dxt-muted";
27pub const THEME_TOKEN_PANEL: &str = "--dxt-panel";
28pub const THEME_TOKEN_PANEL_BORDER: &str = "--dxt-panel-border";
29pub const THEME_TOKEN_ACCENT: &str = "--dxt-accent";
30pub const THEME_TOKEN_BACKGROUND: &str = THEME_TOKEN_BG;
31pub const THEME_TOKEN_TEXT: &str = THEME_TOKEN_FG;
32pub const THEME_TOKEN_SURFACE: &str = THEME_TOKEN_PANEL;
33pub const THEME_TOKEN_SURFACE_BORDER: &str = THEME_TOKEN_PANEL_BORDER;
34pub const THEME_CHANGE_EVENT: &str = "dioxus-theme:change";
35pub const THEME_VISUAL_TOKEN_MANIFEST_VERSION: u8 = 1;
36
37#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
38#[serde(rename_all = "kebab-case")]
39pub enum ThemeVisualTokenRole {
40 Background,
41 Text,
42 Muted,
43 Surface,
44 SurfaceBorder,
45 Accent,
46}
47
48impl ThemeVisualTokenRole {
49 pub const fn as_attr(self) -> &'static str {
50 match self {
51 Self::Background => "background",
52 Self::Text => "text",
53 Self::Muted => "muted",
54 Self::Surface => "surface",
55 Self::SurfaceBorder => "surface-border",
56 Self::Accent => "accent",
57 }
58 }
59
60 pub const fn js_key(self) -> &'static str {
61 match self {
62 Self::Background => "background",
63 Self::Text => "text",
64 Self::Muted => "muted",
65 Self::Surface => "surface",
66 Self::SurfaceBorder => "surfaceBorder",
67 Self::Accent => "accent",
68 }
69 }
70
71 pub const fn css_var(self) -> &'static str {
72 match self {
73 Self::Background => THEME_TOKEN_BACKGROUND,
74 Self::Text => THEME_TOKEN_TEXT,
75 Self::Muted => THEME_TOKEN_MUTED,
76 Self::Surface => THEME_TOKEN_SURFACE,
77 Self::SurfaceBorder => THEME_TOKEN_SURFACE_BORDER,
78 Self::Accent => THEME_TOKEN_ACCENT,
79 }
80 }
81}
82
83#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
84#[serde(rename_all = "camelCase")]
85pub struct ThemeVisualTokenDefinition {
86 pub role: ThemeVisualTokenRole,
87 pub key: &'static str,
88 pub css_var: &'static str,
89}
90
91#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
92#[serde(rename_all = "camelCase")]
93pub struct ThemeVisualTokenManifest {
94 pub version: u8,
95 pub change_event: &'static str,
96 pub tokens: &'static [ThemeVisualTokenDefinition],
97}
98
99pub const THEME_VISUAL_TOKENS: [ThemeVisualTokenDefinition; 6] = [
100 ThemeVisualTokenDefinition {
101 role: ThemeVisualTokenRole::Background,
102 key: ThemeVisualTokenRole::Background.js_key(),
103 css_var: THEME_TOKEN_BACKGROUND,
104 },
105 ThemeVisualTokenDefinition {
106 role: ThemeVisualTokenRole::Text,
107 key: ThemeVisualTokenRole::Text.js_key(),
108 css_var: THEME_TOKEN_TEXT,
109 },
110 ThemeVisualTokenDefinition {
111 role: ThemeVisualTokenRole::Muted,
112 key: ThemeVisualTokenRole::Muted.js_key(),
113 css_var: THEME_TOKEN_MUTED,
114 },
115 ThemeVisualTokenDefinition {
116 role: ThemeVisualTokenRole::Surface,
117 key: ThemeVisualTokenRole::Surface.js_key(),
118 css_var: THEME_TOKEN_SURFACE,
119 },
120 ThemeVisualTokenDefinition {
121 role: ThemeVisualTokenRole::SurfaceBorder,
122 key: ThemeVisualTokenRole::SurfaceBorder.js_key(),
123 css_var: THEME_TOKEN_SURFACE_BORDER,
124 },
125 ThemeVisualTokenDefinition {
126 role: ThemeVisualTokenRole::Accent,
127 key: ThemeVisualTokenRole::Accent.js_key(),
128 css_var: THEME_TOKEN_ACCENT,
129 },
130];
131
132pub fn theme_visual_token_css_var(alias: impl AsRef<str>) -> Option<&'static str> {
133 match alias.as_ref().trim() {
134 "background" | "bg" | "canvas" => Some(THEME_TOKEN_BACKGROUND),
135 "text" | "fg" | "foreground" => Some(THEME_TOKEN_TEXT),
136 "muted" | "subtle" => Some(THEME_TOKEN_MUTED),
137 "surface" | "panel" => Some(THEME_TOKEN_SURFACE),
138 "surface-border" | "panel-border" | "border" => Some(THEME_TOKEN_SURFACE_BORDER),
139 "accent" | "primary" => Some(THEME_TOKEN_ACCENT),
140 _ => None,
141 }
142}
143
144pub const fn theme_visual_token_manifest() -> ThemeVisualTokenManifest {
145 ThemeVisualTokenManifest {
146 version: THEME_VISUAL_TOKEN_MANIFEST_VERSION,
147 change_event: THEME_CHANGE_EVENT,
148 tokens: &THEME_VISUAL_TOKENS,
149 }
150}
151
152pub fn theme_visual_token_manifest_json() -> Result<String, serde_json::Error> {
153 static MANIFEST_JSON: OnceLock<String> = OnceLock::new();
154 Ok(MANIFEST_JSON
155 .get_or_init(|| {
156 serde_json::to_string(&theme_visual_token_manifest())
157 .expect("theme visual token manifest serializes")
158 })
159 .clone())
160}
161
162#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
163#[serde(rename_all = "kebab-case")]
164#[derive(Default)]
165pub enum ThemeColorScheme {
166 Light,
167 Dark,
168 #[default]
169 System,
170 Normal,
171}
172
173impl ThemeColorScheme {
174 pub fn as_css(self) -> &'static str {
175 match self {
176 Self::Light => "light",
177 Self::Dark => "dark",
178 Self::System => "light dark",
179 Self::Normal => "normal",
180 }
181 }
182}
183
184#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
185#[serde(rename_all = "kebab-case")]
186#[derive(Default)]
187pub enum ThemeAnimationMode {
188 #[default]
189 ViewTransition,
190 CssOnly,
191 None,
192}
193
194impl ThemeAnimationMode {
195 pub fn as_attr(self) -> &'static str {
196 match self {
197 Self::ViewTransition => "view-transition",
198 Self::CssOnly => "css-only",
199 Self::None => "none",
200 }
201 }
202
203 pub fn is_animated(self) -> bool {
204 !matches!(self, Self::None)
205 }
206}
207
208#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
209#[serde(rename_all = "kebab-case")]
210#[derive(Default)]
211pub enum ThemeAnimationPreset {
212 Fade,
213 #[default]
214 CrossFade,
215 Slide,
216 RadialWipe,
217 MaskedWave,
218}
219
220impl ThemeAnimationPreset {
221 pub const fn all() -> &'static [Self; 5] {
222 &[
223 Self::Fade,
224 Self::CrossFade,
225 Self::Slide,
226 Self::RadialWipe,
227 Self::MaskedWave,
228 ]
229 }
230
231 pub const fn as_attr(self) -> &'static str {
232 match self {
233 Self::Fade => "fade",
234 Self::CrossFade => "cross-fade",
235 Self::Slide => "slide",
236 Self::RadialWipe => "radial-wipe",
237 Self::MaskedWave => "masked-wave",
238 }
239 }
240
241 pub const fn label(self) -> &'static str {
242 match self {
243 Self::Fade => "Fade",
244 Self::CrossFade => "Cross fade",
245 Self::Slide => "Slide",
246 Self::RadialWipe => "Radial wipe",
247 Self::MaskedWave => "Masked wave",
248 }
249 }
250}
251
252#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
253#[serde(rename_all = "kebab-case")]
254#[derive(Default)]
255pub enum ThemeReducedMotion {
256 #[default]
257 Respect,
258 Ignore,
259}
260
261#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
262#[serde(rename_all = "kebab-case")]
263pub enum ThemeValidationSeverity {
264 Error,
265 Warning,
266}
267
268#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
269#[serde(rename_all = "kebab-case")]
270pub enum ThemeValidationCode {
271 EmptyStorageKey,
272 EmptyAnimationStorageKey,
273 EmptyAnimationSpeedStorageKey,
274 MissingDefaultTheme,
275 MissingSystemLightTheme,
276 MissingSystemDarkTheme,
277 InvalidTarget,
278 InvalidAttribute,
279 InvalidTokenName,
280 UnsafeTokenValue,
281}
282
283#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
284#[serde(rename_all = "camelCase")]
285pub struct ThemeValidationIssue {
286 pub severity: ThemeValidationSeverity,
287 pub code: ThemeValidationCode,
288 pub message: String,
289 #[serde(default, skip_serializing_if = "Option::is_none")]
290 pub field: Option<String>,
291 #[serde(default, skip_serializing_if = "Option::is_none")]
292 pub theme: Option<String>,
293}
294
295impl ThemeValidationIssue {
296 pub fn error(
297 code: ThemeValidationCode,
298 field: impl Into<String>,
299 message: impl Into<String>,
300 ) -> Self {
301 Self {
302 severity: ThemeValidationSeverity::Error,
303 code,
304 message: message.into(),
305 field: Some(field.into()),
306 theme: None,
307 }
308 }
309
310 pub fn token_error(
311 code: ThemeValidationCode,
312 theme: impl Into<String>,
313 field: impl Into<String>,
314 message: impl Into<String>,
315 ) -> Self {
316 Self {
317 severity: ThemeValidationSeverity::Error,
318 code,
319 message: message.into(),
320 field: Some(field.into()),
321 theme: Some(theme.into()),
322 }
323 }
324}
325
326#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
327#[serde(rename_all = "camelCase")]
328pub struct ThemeValidationReport {
329 pub issues: Vec<ThemeValidationIssue>,
330}
331
332impl ThemeValidationReport {
333 pub fn is_valid(&self) -> bool {
334 self.issues
335 .iter()
336 .all(|issue| issue.severity != ThemeValidationSeverity::Error)
337 }
338
339 pub fn errors(&self) -> impl Iterator<Item = &ThemeValidationIssue> {
340 self.issues
341 .iter()
342 .filter(|issue| issue.severity == ThemeValidationSeverity::Error)
343 }
344
345 pub fn warnings(&self) -> impl Iterator<Item = &ThemeValidationIssue> {
346 self.issues
347 .iter()
348 .filter(|issue| issue.severity == ThemeValidationSeverity::Warning)
349 }
350
351 pub fn push(&mut self, issue: ThemeValidationIssue) {
352 self.issues.push(issue);
353 }
354}
355
356impl ThemeReducedMotion {
357 pub fn as_attr(self) -> &'static str {
358 match self {
359 Self::Respect => "respect",
360 Self::Ignore => "ignore",
361 }
362 }
363}
364
365#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
366#[serde(rename_all = "camelCase")]
367pub struct ThemeDefinition {
368 pub id: String,
369 pub label: String,
370 #[serde(default)]
371 pub color_scheme: ThemeColorScheme,
372 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
373 pub tokens: BTreeMap<String, String>,
374}
375
376impl ThemeDefinition {
377 pub fn new(id: impl AsRef<str>, label: impl Into<String>) -> Self {
378 Self {
379 id: theme_id(id),
380 label: label.into(),
381 color_scheme: ThemeColorScheme::System,
382 tokens: BTreeMap::new(),
383 }
384 }
385
386 pub fn light() -> Self {
387 Self::new("light", "Light")
388 .with_color_scheme(ThemeColorScheme::Light)
389 .with_visual_token(ThemeVisualTokenRole::Background, "#f8fafc")
390 .with_visual_token(ThemeVisualTokenRole::Text, "#0f172a")
391 .with_visual_token(ThemeVisualTokenRole::Muted, "#475569")
392 .with_visual_token(ThemeVisualTokenRole::Surface, "#ffffff")
393 .with_visual_token(ThemeVisualTokenRole::SurfaceBorder, "rgba(15,23,42,0.12)")
394 .with_visual_token(ThemeVisualTokenRole::Accent, "#0891b2")
395 }
396
397 pub fn dark() -> Self {
398 Self::new("dark", "Dark")
399 .with_color_scheme(ThemeColorScheme::Dark)
400 .with_visual_token(ThemeVisualTokenRole::Background, "#020617")
401 .with_visual_token(ThemeVisualTokenRole::Text, "#f8fafc")
402 .with_visual_token(ThemeVisualTokenRole::Muted, "#cbd5e1")
403 .with_visual_token(ThemeVisualTokenRole::Surface, "rgba(15,23,42,0.74)")
404 .with_visual_token(
405 ThemeVisualTokenRole::SurfaceBorder,
406 "rgba(255,255,255,0.10)",
407 )
408 .with_visual_token(ThemeVisualTokenRole::Accent, "#22d3ee")
409 }
410
411 pub fn system() -> Self {
412 Self::new("system", "System").with_color_scheme(ThemeColorScheme::System)
413 }
414
415 pub fn with_label(mut self, label: impl Into<String>) -> Self {
416 self.label = label.into();
417 self
418 }
419
420 pub fn label(self, label: impl Into<String>) -> Self {
421 self.with_label(label)
422 }
423
424 pub fn with_color_scheme(mut self, color_scheme: ThemeColorScheme) -> Self {
425 self.color_scheme = color_scheme;
426 self
427 }
428
429 pub fn scheme(self, color_scheme: ThemeColorScheme) -> Self {
430 self.with_color_scheme(color_scheme)
431 }
432
433 pub fn with_token(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
434 let name = name.into();
435 if is_custom_property_name(&name) {
436 self.tokens.insert(name, value.into());
437 }
438 self
439 }
440
441 pub fn token(self, name: impl Into<String>, value: impl Into<String>) -> Self {
442 self.with_token(name, value)
443 }
444
445 pub fn with_visual_token(self, role: ThemeVisualTokenRole, value: impl Into<String>) -> Self {
446 self.with_token(role.css_var(), value)
447 }
448
449 pub fn visual(self, role: ThemeVisualTokenRole, value: impl Into<String>) -> Self {
450 self.with_visual_token(role, value)
451 }
452
453 pub fn with_visual_tokens<I, V>(mut self, tokens: I) -> Self
454 where
455 I: IntoIterator<Item = (ThemeVisualTokenRole, V)>,
456 V: Into<String>,
457 {
458 for (role, value) in tokens {
459 self = self.with_visual_token(role, value);
460 }
461 self
462 }
463
464 pub fn visuals<I, V>(self, tokens: I) -> Self
465 where
466 I: IntoIterator<Item = (ThemeVisualTokenRole, V)>,
467 V: Into<String>,
468 {
469 self.with_visual_tokens(tokens)
470 }
471
472 pub fn with_tokens<I, K, V>(mut self, tokens: I) -> Self
473 where
474 I: IntoIterator<Item = (K, V)>,
475 K: Into<String>,
476 V: Into<String>,
477 {
478 for (name, value) in tokens {
479 let name = name.into();
480 if is_custom_property_name(&name) {
481 self.tokens.insert(name, value.into());
482 }
483 }
484 self
485 }
486
487 pub fn tokens<I, K, V>(self, tokens: I) -> Self
488 where
489 I: IntoIterator<Item = (K, V)>,
490 K: Into<String>,
491 V: Into<String>,
492 {
493 self.with_tokens(tokens)
494 }
495
496 pub fn is_system(&self) -> bool {
497 self.id == "system"
498 }
499}
500
501#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
502#[serde(rename_all = "camelCase")]
503pub struct ThemeRegistry {
504 pub themes: Vec<ThemeDefinition>,
505}
506
507impl Default for ThemeRegistry {
508 fn default() -> Self {
509 Self::defaults()
510 }
511}
512
513impl ThemeRegistry {
514 pub fn new() -> Self {
515 Self { themes: Vec::new() }
516 }
517
518 pub fn defaults() -> Self {
519 Self::new()
520 .with_theme(ThemeDefinition::light())
521 .with_theme(ThemeDefinition::dark())
522 .with_theme(ThemeDefinition::system())
523 }
524
525 pub fn with_theme(mut self, theme: ThemeDefinition) -> Self {
526 self.insert_theme(theme);
527 self
528 }
529
530 #[allow(clippy::should_implement_trait)]
531 pub fn add(self, theme: ThemeDefinition) -> Self {
532 self.with_theme(theme)
533 }
534
535 pub fn insert_theme(&mut self, theme: ThemeDefinition) -> Option<ThemeDefinition> {
536 if let Some(existing) = self
537 .themes
538 .iter_mut()
539 .find(|candidate| candidate.id == theme.id)
540 {
541 return Some(std::mem::replace(existing, theme));
542 }
543 self.themes.push(theme);
544 None
545 }
546
547 pub fn contains_theme(&self, id: impl AsRef<str>) -> bool {
548 let id = theme_id(id);
549 self.themes.iter().any(|theme| theme.id == id)
550 }
551
552 pub fn theme(&self, id: impl AsRef<str>) -> Option<&ThemeDefinition> {
553 let id = theme_id(id);
554 self.themes.iter().find(|theme| theme.id == id)
555 }
556
557 pub fn theme_ids(&self) -> Vec<&str> {
558 self.themes.iter().map(|theme| theme.id.as_str()).collect()
559 }
560
561 pub fn ids(&self) -> Vec<&str> {
562 self.theme_ids()
563 }
564
565 pub fn first_non_system_theme(&self) -> Option<&ThemeDefinition> {
566 self.themes.iter().find(|theme| !theme.is_system())
567 }
568}
569
570impl std::ops::Add<ThemeDefinition> for ThemeRegistry {
571 type Output = Self;
572
573 fn add(self, rhs: ThemeDefinition) -> Self::Output {
574 self.with_theme(rhs)
575 }
576}
577
578pub type ThemeCfg = ThemeConfig;
579pub type ThemeDef = ThemeDefinition;
580pub type ThemeReg = ThemeRegistry;
581pub type ThemeAnim = ThemeAnimationMode;
582pub type ThemePreset = ThemeAnimationPreset;
583
584#[derive(Debug, Clone, PartialEq, Eq)]
585pub struct ThemeMotion {
586 pub duration_ms: Option<u32>,
587 pub easing: Option<String>,
588 pub reduced_motion: Option<ThemeReducedMotion>,
589 pub animation: Option<ThemeAnimationMode>,
590 pub preset: Option<ThemeAnimationPreset>,
591 pub speed: Option<u16>,
592}
593
594impl ThemeMotion {
595 pub fn new() -> Self {
596 Self {
597 duration_ms: None,
598 easing: None,
599 reduced_motion: None,
600 animation: None,
601 preset: None,
602 speed: None,
603 }
604 }
605
606 pub fn dur(mut self, duration: Duration) -> Self {
607 self.duration_ms = Some(duration.as_millis().min(u128::from(u32::MAX)) as u32);
608 self
609 }
610
611 pub fn dur_ms(mut self, duration_ms: u32) -> Self {
612 self.duration_ms = Some(duration_ms);
613 self
614 }
615
616 pub fn ease(mut self, easing: impl Into<String>) -> Self {
617 self.easing = Some(easing.into());
618 self
619 }
620
621 pub fn reduced(mut self, reduced_motion: ThemeReducedMotion) -> Self {
622 self.reduced_motion = Some(reduced_motion);
623 self
624 }
625
626 pub fn anim(mut self, animation: ThemeAnimationMode) -> Self {
627 self.animation = Some(animation);
628 self
629 }
630
631 pub fn preset(mut self, preset: ThemeAnimationPreset) -> Self {
632 self.preset = Some(preset);
633 self
634 }
635
636 pub fn speed(mut self, speed: u16) -> Self {
637 self.speed = Some(normalize_animation_speed(speed));
638 self
639 }
640}
641
642impl Default for ThemeMotion {
643 fn default() -> Self {
644 Self::new()
645 }
646}
647
648pub fn theme() -> ThemeConfig {
649 ThemeConfig::new()
650}
651
652pub fn theme_def(id: impl AsRef<str>, label: impl Into<String>) -> ThemeDefinition {
653 ThemeDefinition::new(id, label)
654}
655
656pub fn themes() -> ThemeRegistry {
657 ThemeRegistry::new()
658}
659
660pub fn default_themes() -> ThemeRegistry {
661 ThemeRegistry::defaults()
662}
663
664pub fn motion() -> ThemeMotion {
665 ThemeMotion::new()
666}
667
668#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
669#[serde(rename_all = "camelCase")]
670pub struct ThemeConfig {
671 pub registry: ThemeRegistry,
672 pub default_theme: String,
673 pub system_light_theme: String,
674 pub system_dark_theme: String,
675 pub storage_key: String,
676 pub attribute: String,
677 pub target: String,
678 pub duration_ms: u32,
679 pub easing: String,
680 pub reduced_motion: ThemeReducedMotion,
681 pub animation: ThemeAnimationMode,
682 pub animation_preset: ThemeAnimationPreset,
683 pub animation_storage_key: String,
684 pub animation_speed: u16,
685 pub animation_speed_storage_key: String,
686 pub isolate_view_transition_names: bool,
687 pub runtime_path: String,
688}
689
690impl Default for ThemeConfig {
691 fn default() -> Self {
692 Self::new()
693 }
694}
695
696impl ThemeConfig {
697 pub fn new() -> Self {
698 Self {
699 registry: ThemeRegistry::default(),
700 default_theme: "system".to_string(),
701 system_light_theme: "light".to_string(),
702 system_dark_theme: "dark".to_string(),
703 storage_key: DEFAULT_THEME_STORAGE_KEY.to_string(),
704 attribute: DEFAULT_THEME_ATTRIBUTE.to_string(),
705 target: DEFAULT_THEME_TARGET.to_string(),
706 duration_ms: DEFAULT_THEME_DURATION_MS,
707 easing: DEFAULT_THEME_EASING.to_string(),
708 reduced_motion: ThemeReducedMotion::Respect,
709 animation: ThemeAnimationMode::ViewTransition,
710 animation_preset: ThemeAnimationPreset::CrossFade,
711 animation_storage_key: DEFAULT_THEME_ANIMATION_STORAGE_KEY.to_string(),
712 animation_speed: DEFAULT_THEME_ANIMATION_SPEED,
713 animation_speed_storage_key: DEFAULT_THEME_ANIMATION_SPEED_STORAGE_KEY.to_string(),
714 isolate_view_transition_names: true,
715 runtime_path: DEFAULT_THEME_RUNTIME_PATH.to_string(),
716 }
717 }
718
719 pub fn with_registry(mut self, registry: ThemeRegistry) -> Self {
720 self.registry = registry;
721 self
722 }
723
724 pub fn registry(self, registry: ThemeRegistry) -> Self {
725 self.with_registry(registry)
726 }
727
728 pub fn with_theme(mut self, theme: ThemeDefinition) -> Self {
729 self.registry.insert_theme(theme);
730 self
731 }
732
733 pub fn theme(self, theme: ThemeDefinition) -> Self {
734 self.with_theme(theme)
735 }
736
737 #[allow(clippy::should_implement_trait)]
738 pub fn add(self, theme: ThemeDefinition) -> Self {
739 self.with_theme(theme)
740 }
741
742 pub fn with_default_theme(mut self, theme: impl AsRef<str>) -> Self {
743 self.default_theme = theme_id(theme);
744 self
745 }
746
747 pub fn default_theme(self, theme: impl AsRef<str>) -> Self {
748 self.with_default_theme(theme)
749 }
750
751 pub fn with_system_theme(
752 mut self,
753 light_theme: impl AsRef<str>,
754 dark_theme: impl AsRef<str>,
755 ) -> Self {
756 self.system_light_theme = theme_id(light_theme);
757 self.system_dark_theme = theme_id(dark_theme);
758 self
759 }
760
761 pub fn system_theme(self, light_theme: impl AsRef<str>, dark_theme: impl AsRef<str>) -> Self {
762 self.with_system_theme(light_theme, dark_theme)
763 }
764
765 pub fn with_storage_key(mut self, storage_key: impl Into<String>) -> Self {
766 self.storage_key = storage_key.into();
767 self
768 }
769
770 pub fn storage(self, storage_key: impl Into<String>) -> Self {
771 self.with_storage_key(storage_key)
772 }
773
774 pub fn with_attribute(mut self, attribute: impl Into<String>) -> Self {
775 self.attribute = attribute.into();
776 self
777 }
778
779 pub fn attr(self, attribute: impl Into<String>) -> Self {
780 self.with_attribute(attribute)
781 }
782
783 pub fn with_target(mut self, target: impl Into<String>) -> Self {
784 self.target = target.into();
785 self
786 }
787
788 pub fn target(self, target: impl Into<String>) -> Self {
789 self.with_target(target)
790 }
791
792 pub fn with_duration_ms(mut self, duration_ms: u32) -> Self {
793 self.duration_ms = duration_ms;
794 self
795 }
796
797 pub fn dur(self, duration: Duration) -> Self {
798 self.with_duration_ms(duration.as_millis().min(u128::from(u32::MAX)) as u32)
799 }
800
801 pub fn dur_ms(self, duration_ms: u32) -> Self {
802 self.with_duration_ms(duration_ms)
803 }
804
805 pub fn with_easing(mut self, easing: impl Into<String>) -> Self {
806 self.easing = easing.into();
807 self
808 }
809
810 pub fn ease(self, easing: impl Into<String>) -> Self {
811 self.with_easing(easing)
812 }
813
814 pub fn with_reduced_motion(mut self, reduced_motion: ThemeReducedMotion) -> Self {
815 self.reduced_motion = reduced_motion;
816 self
817 }
818
819 pub fn reduced(self, reduced_motion: ThemeReducedMotion) -> Self {
820 self.with_reduced_motion(reduced_motion)
821 }
822
823 #[cfg(feature = "viewtx")]
824 pub fn with_viewtx_timing(mut self, config: &dioxus_viewtx_core::ViewTransitionConfig) -> Self {
825 self.duration_ms = config.duration_ms;
826 self.easing = config.easing.clone();
827 self.reduced_motion = match config.reduced_motion {
828 dioxus_viewtx_core::ViewTransitionReducedMotion::Ignore => ThemeReducedMotion::Ignore,
829 dioxus_viewtx_core::ViewTransitionReducedMotion::Disable
830 | dioxus_viewtx_core::ViewTransitionReducedMotion::FadeOnly => {
831 ThemeReducedMotion::Respect
832 }
833 };
834 self
835 }
836
837 #[cfg(feature = "viewtx")]
838 pub fn with_viewtx_motion_policy(
839 mut self,
840 policy: &dioxus_viewtx_core::ViewMotionPolicy,
841 ) -> Self {
842 self.duration_ms = policy.duration_ms;
843 self.easing = policy.easing.clone();
844 self.reduced_motion = match policy.reduced_motion {
845 dioxus_viewtx_core::ViewTransitionReducedMotion::Ignore => ThemeReducedMotion::Ignore,
846 dioxus_viewtx_core::ViewTransitionReducedMotion::Disable
847 | dioxus_viewtx_core::ViewTransitionReducedMotion::FadeOnly => {
848 ThemeReducedMotion::Respect
849 }
850 };
851 self.isolate_view_transition_names = policy.isolate_view_transition_names();
852 self
853 }
854
855 pub fn with_animation(mut self, animation: ThemeAnimationMode) -> Self {
856 self.animation = animation;
857 self
858 }
859
860 pub fn anim(self, animation: ThemeAnimationMode) -> Self {
861 self.with_animation(animation)
862 }
863
864 pub fn with_animation_preset(mut self, animation_preset: ThemeAnimationPreset) -> Self {
865 self.animation_preset = animation_preset;
866 self
867 }
868
869 pub fn preset(self, animation_preset: ThemeAnimationPreset) -> Self {
870 self.with_animation_preset(animation_preset)
871 }
872
873 pub fn with_animation_storage_key(mut self, animation_storage_key: impl Into<String>) -> Self {
874 self.animation_storage_key = animation_storage_key.into();
875 self
876 }
877
878 pub fn anim_storage(self, animation_storage_key: impl Into<String>) -> Self {
879 self.with_animation_storage_key(animation_storage_key)
880 }
881
882 pub fn with_animation_speed(mut self, animation_speed: u16) -> Self {
883 self.animation_speed = normalize_animation_speed(animation_speed);
884 self
885 }
886
887 pub fn speed(self, animation_speed: u16) -> Self {
888 self.with_animation_speed(animation_speed)
889 }
890
891 pub fn with_animation_speed_storage_key(
892 mut self,
893 animation_speed_storage_key: impl Into<String>,
894 ) -> Self {
895 self.animation_speed_storage_key = animation_speed_storage_key.into();
896 self
897 }
898
899 pub fn speed_storage(self, animation_speed_storage_key: impl Into<String>) -> Self {
900 self.with_animation_speed_storage_key(animation_speed_storage_key)
901 }
902
903 pub fn with_view_transition_name_isolation(mut self, isolate: bool) -> Self {
904 self.isolate_view_transition_names = isolate;
905 self
906 }
907
908 pub fn isolate_names(self, isolate: bool) -> Self {
909 self.with_view_transition_name_isolation(isolate)
910 }
911
912 pub fn with_runtime_path(mut self, runtime_path: impl Into<String>) -> Self {
913 self.runtime_path = runtime_path.into();
914 self
915 }
916
917 pub fn runtime(self, runtime_path: impl Into<String>) -> Self {
918 self.with_runtime_path(runtime_path)
919 }
920
921 pub fn motion(mut self, motion: ThemeMotion) -> Self {
922 if let Some(duration_ms) = motion.duration_ms {
923 self.duration_ms = duration_ms;
924 }
925 if let Some(easing) = motion.easing {
926 self.easing = easing;
927 }
928 if let Some(reduced_motion) = motion.reduced_motion {
929 self.reduced_motion = reduced_motion;
930 }
931 if let Some(animation) = motion.animation {
932 self.animation = animation;
933 }
934 if let Some(preset) = motion.preset {
935 self.animation_preset = preset;
936 }
937 if let Some(speed) = motion.speed {
938 self.animation_speed = speed;
939 }
940 self
941 }
942
943 pub fn validate(&self) -> ThemeValidationReport {
944 let mut report = ThemeValidationReport::default();
945 if self.storage_key.trim().is_empty() {
946 report.push(ThemeValidationIssue::error(
947 ThemeValidationCode::EmptyStorageKey,
948 "storage_key",
949 "theme storage key must not be empty",
950 ));
951 }
952 if self.animation_storage_key.trim().is_empty() {
953 report.push(ThemeValidationIssue::error(
954 ThemeValidationCode::EmptyAnimationStorageKey,
955 "animation_storage_key",
956 "animation preset storage key must not be empty",
957 ));
958 }
959 if self.animation_speed_storage_key.trim().is_empty() {
960 report.push(ThemeValidationIssue::error(
961 ThemeValidationCode::EmptyAnimationSpeedStorageKey,
962 "animation_speed_storage_key",
963 "animation speed storage key must not be empty",
964 ));
965 }
966 if !self.registry.contains_theme(&self.default_theme) {
967 report.push(ThemeValidationIssue::error(
968 ThemeValidationCode::MissingDefaultTheme,
969 "default_theme",
970 format!("default theme `{}` is not registered", self.default_theme),
971 ));
972 }
973 if !self.registry.contains_theme(&self.system_light_theme) {
974 report.push(ThemeValidationIssue::error(
975 ThemeValidationCode::MissingSystemLightTheme,
976 "system_light_theme",
977 format!(
978 "system light theme `{}` is not registered",
979 self.system_light_theme
980 ),
981 ));
982 }
983 if !self.registry.contains_theme(&self.system_dark_theme) {
984 report.push(ThemeValidationIssue::error(
985 ThemeValidationCode::MissingSystemDarkTheme,
986 "system_dark_theme",
987 format!(
988 "system dark theme `{}` is not registered",
989 self.system_dark_theme
990 ),
991 ));
992 }
993 if !is_valid_theme_target(&self.target) {
994 report.push(ThemeValidationIssue::error(
995 ThemeValidationCode::InvalidTarget,
996 "target",
997 "theme target must be html, :root, or a simple selector",
998 ));
999 }
1000 if !is_valid_theme_attribute(&self.attribute) {
1001 report.push(ThemeValidationIssue::error(
1002 ThemeValidationCode::InvalidAttribute,
1003 "attribute",
1004 "theme attribute must be a non-empty attribute name",
1005 ));
1006 }
1007 for theme in &self.registry.themes {
1008 for (name, value) in &theme.tokens {
1009 if !is_custom_property_name(name) {
1010 report.push(ThemeValidationIssue::token_error(
1011 ThemeValidationCode::InvalidTokenName,
1012 theme.id.clone(),
1013 name.clone(),
1014 "theme token names must be CSS custom properties",
1015 ));
1016 }
1017 if !is_safe_css_token_value(value) {
1018 report.push(ThemeValidationIssue::token_error(
1019 ThemeValidationCode::UnsafeTokenValue,
1020 theme.id.clone(),
1021 name.clone(),
1022 "theme token values must be non-empty safe CSS values",
1023 ));
1024 }
1025 }
1026 }
1027 report
1028 }
1029
1030 pub fn resolve_theme(&self, id: impl AsRef<str>) -> Option<&ThemeDefinition> {
1031 let id = theme_id(id);
1032 self.registry
1033 .theme(&id)
1034 .or_else(|| self.registry.theme(&self.default_theme))
1035 .or_else(|| self.registry.first_non_system_theme())
1036 }
1037
1038 pub fn toggle_theme_id(&self, current: impl AsRef<str>) -> String {
1039 let current = theme_id(current);
1040 let default = if self.default_theme == "system" {
1041 self.system_dark_theme.as_str()
1042 } else {
1043 self.default_theme.as_str()
1044 };
1045 if current == default {
1046 self.registry
1047 .themes
1048 .iter()
1049 .find(|theme| !theme.is_system() && theme.id != default)
1050 .map(|theme| theme.id.clone())
1051 .unwrap_or_else(|| default.to_string())
1052 } else {
1053 default.to_string()
1054 }
1055 }
1056
1057 pub fn to_json(&self) -> Result<String, serde_json::Error> {
1058 serde_json::to_string(self)
1059 }
1060
1061 pub fn to_compact_json(&self) -> Result<String, serde_json::Error> {
1062 let mut value = serde_json::to_value(self)?;
1063 let default = serde_json::to_value(ThemeConfig::default())?;
1064 if let (Some(object), Some(defaults)) = (value.as_object_mut(), default.as_object()) {
1065 for key in [
1066 "defaultTheme",
1067 "systemLightTheme",
1068 "systemDarkTheme",
1069 "storageKey",
1070 "attribute",
1071 "target",
1072 "durationMs",
1073 "easing",
1074 "reducedMotion",
1075 "animation",
1076 "animationPreset",
1077 "animationStorageKey",
1078 "animationSpeed",
1079 "animationSpeedStorageKey",
1080 "isolateViewTransitionNames",
1081 "runtimePath",
1082 ] {
1083 if object.get(key) == defaults.get(key) {
1084 object.remove(key);
1085 }
1086 }
1087 }
1088 serde_json::to_string(&value)
1089 }
1090
1091 pub fn to_preferred_json(
1092 &self,
1093 format: ThemeSerializationFormat,
1094 ) -> Result<String, serde_json::Error> {
1095 match format {
1096 ThemeSerializationFormat::StableJson | ThemeSerializationFormat::ReadableJson => {
1097 self.to_json()
1098 }
1099 ThemeSerializationFormat::CompactJson => self.to_compact_json(),
1100 }
1101 }
1102
1103 pub fn with_route_profile(mut self, profile: ThemePresetProfile) -> Self {
1104 profile.apply_to_config(&mut self);
1105 self
1106 }
1107
1108 pub fn route_profile(self, profile: ThemePresetProfile) -> Self {
1109 self.with_route_profile(profile)
1110 }
1111
1112 pub fn cache_key(&self, route: Option<&str>) -> String {
1113 theme_cache_key(self, route, None)
1114 }
1115
1116 pub fn manifest_fragment(&self, policy: &ThemeRoutePolicy) -> ThemeManifestFragment {
1117 theme_manifest_fragment(self, policy)
1118 }
1119
1120 pub fn output_report(&self, policy: &ThemeRoutePolicy) -> ThemeOutputReport {
1121 theme_output_report(self, policy)
1122 }
1123
1124 pub fn explain(&self, policy: &ThemeRoutePolicy) -> ThemeExplainReport {
1125 explain_theme(self, policy)
1126 }
1127
1128 pub fn try_validated(self) -> Result<Self, ThemeConfigError> {
1129 let report = self.validate();
1130 if report.is_valid() {
1131 Ok(self)
1132 } else {
1133 Err(ThemeConfigError { report })
1134 }
1135 }
1136}
1137
1138#[derive(Debug, Clone, PartialEq, Eq)]
1139pub struct ThemeConfigError {
1140 pub report: ThemeValidationReport,
1141}
1142
1143impl fmt::Display for ThemeConfigError {
1144 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1145 let count = self.report.errors().count();
1146 write!(f, "invalid Theme config ({count} error(s))")
1147 }
1148}
1149
1150impl std::error::Error for ThemeConfigError {}
1151
1152#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
1153#[serde(rename_all = "kebab-case")]
1154pub enum ThemeRuntimeEmission {
1155 Always,
1156 #[default]
1157 WhenUsed,
1158 PrepaintOnly,
1159 Disabled,
1160}
1161
1162impl ThemeRuntimeEmission {
1163 pub const fn as_attr(self) -> &'static str {
1164 match self {
1165 Self::Always => "always",
1166 Self::WhenUsed => "when-used",
1167 Self::PrepaintOnly => "prepaint-only",
1168 Self::Disabled => "disabled",
1169 }
1170 }
1171}
1172
1173#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
1174#[serde(rename_all = "kebab-case")]
1175pub enum ThemeSerializationFormat {
1176 #[default]
1177 StableJson,
1178 ReadableJson,
1179 CompactJson,
1180}
1181
1182impl ThemeSerializationFormat {
1183 pub const fn as_attr(self) -> &'static str {
1184 match self {
1185 Self::StableJson => "stable-json",
1186 Self::ReadableJson => "readable-json",
1187 Self::CompactJson => "compact-json",
1188 }
1189 }
1190}
1191
1192#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
1193#[serde(rename_all = "kebab-case")]
1194pub enum ThemeDiagnosticVerbosity {
1195 Off,
1196 Summary,
1197 #[default]
1198 Detailed,
1199}
1200
1201#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
1202#[serde(rename_all = "kebab-case")]
1203pub enum ThemeFallbackStrategy {
1204 #[default]
1205 SystemTheme,
1206 StaticTokens,
1207 NativePort,
1208 DisableRuntime,
1209}
1210
1211#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
1212#[serde(rename_all = "kebab-case")]
1213pub enum ThemePresetProfile {
1214 Conservative,
1215 #[default]
1216 Balanced,
1217 Expressive,
1218}
1219
1220impl ThemePresetProfile {
1221 pub const fn as_attr(self) -> &'static str {
1222 match self {
1223 Self::Conservative => "conservative",
1224 Self::Balanced => "balanced",
1225 Self::Expressive => "expressive",
1226 }
1227 }
1228
1229 pub fn apply_to_config(self, config: &mut ThemeConfig) {
1230 match self {
1231 Self::Conservative => {
1232 config.duration_ms = config.duration_ms.min(120);
1233 config.reduced_motion = ThemeReducedMotion::Respect;
1234 config.animation = ThemeAnimationMode::CssOnly;
1235 config.animation_preset = ThemeAnimationPreset::CrossFade;
1236 config.animation_speed = normalize_animation_speed(75);
1237 config.isolate_view_transition_names = true;
1238 }
1239 Self::Balanced => {
1240 config.duration_ms = config.duration_ms.max(160).min(260);
1241 config.reduced_motion = ThemeReducedMotion::Respect;
1242 config.animation = ThemeAnimationMode::ViewTransition;
1243 config.animation_speed = normalize_animation_speed(config.animation_speed);
1244 }
1245 Self::Expressive => {
1246 config.duration_ms = config.duration_ms.max(260);
1247 config.animation = ThemeAnimationMode::ViewTransition;
1248 config.animation_preset = ThemeAnimationPreset::RadialWipe;
1249 config.animation_speed = normalize_animation_speed(140);
1250 config.isolate_view_transition_names = true;
1251 }
1252 }
1253 }
1254}
1255
1256#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1257#[serde(rename_all = "camelCase")]
1258pub struct ThemeInteropPolicy {
1259 pub strata: bool,
1260 pub resume: bool,
1261 pub native_port: bool,
1262 pub viewtx: bool,
1263 pub hoverfx: bool,
1264 pub textfx: bool,
1265}
1266
1267impl Default for ThemeInteropPolicy {
1268 fn default() -> Self {
1269 Self {
1270 strata: true,
1271 resume: true,
1272 native_port: true,
1273 viewtx: true,
1274 hoverfx: true,
1275 textfx: true,
1276 }
1277 }
1278}
1279
1280#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1281#[serde(rename_all = "camelCase")]
1282pub struct ThemeOutputBudget {
1283 #[serde(default, skip_serializing_if = "Option::is_none")]
1284 pub max_config_bytes: Option<usize>,
1285 #[serde(default, skip_serializing_if = "Option::is_none")]
1286 pub max_runtime_bytes: Option<usize>,
1287 #[serde(default, skip_serializing_if = "Option::is_none")]
1288 pub max_style_bytes: Option<usize>,
1289 #[serde(default, skip_serializing_if = "Option::is_none")]
1290 pub max_theme_count: Option<usize>,
1291}
1292
1293impl ThemeOutputBudget {
1294 pub fn new() -> Self {
1295 Self::default()
1296 }
1297
1298 pub fn config_bytes(mut self, max: usize) -> Self {
1299 self.max_config_bytes = Some(max);
1300 self
1301 }
1302
1303 pub fn runtime_bytes(mut self, max: usize) -> Self {
1304 self.max_runtime_bytes = Some(max);
1305 self
1306 }
1307
1308 pub fn style_bytes(mut self, max: usize) -> Self {
1309 self.max_style_bytes = Some(max);
1310 self
1311 }
1312
1313 pub fn theme_count(mut self, max: usize) -> Self {
1314 self.max_theme_count = Some(max);
1315 self
1316 }
1317}
1318
1319#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1320#[serde(rename_all = "camelCase")]
1321pub struct ThemeRoutePolicy {
1322 #[serde(default, skip_serializing_if = "Option::is_none")]
1323 pub route: Option<String>,
1324 pub enabled: bool,
1325 pub profile: ThemePresetProfile,
1326 pub emission: ThemeRuntimeEmission,
1327 pub serialization: ThemeSerializationFormat,
1328 pub diagnostics: ThemeDiagnosticVerbosity,
1329 pub fallback: ThemeFallbackStrategy,
1330 #[serde(default)]
1331 pub interop: ThemeInteropPolicy,
1332 #[serde(default)]
1333 pub budget: ThemeOutputBudget,
1334 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1335 pub labels: BTreeMap<String, String>,
1336 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1337 pub tags: Vec<String>,
1338}
1339
1340impl Default for ThemeRoutePolicy {
1341 fn default() -> Self {
1342 Self {
1343 route: None,
1344 enabled: true,
1345 profile: ThemePresetProfile::Balanced,
1346 emission: ThemeRuntimeEmission::WhenUsed,
1347 serialization: ThemeSerializationFormat::StableJson,
1348 diagnostics: ThemeDiagnosticVerbosity::Detailed,
1349 fallback: ThemeFallbackStrategy::SystemTheme,
1350 interop: ThemeInteropPolicy::default(),
1351 budget: ThemeOutputBudget::default(),
1352 labels: BTreeMap::new(),
1353 tags: Vec::new(),
1354 }
1355 }
1356}
1357
1358impl ThemeRoutePolicy {
1359 pub fn new() -> Self {
1360 Self::default()
1361 }
1362
1363 pub fn route(mut self, route: impl Into<String>) -> Self {
1364 self.route = Some(route.into());
1365 self
1366 }
1367
1368 pub fn enabled(mut self, enabled: bool) -> Self {
1369 self.enabled = enabled;
1370 self
1371 }
1372
1373 pub fn profile(mut self, profile: ThemePresetProfile) -> Self {
1374 self.profile = profile;
1375 self
1376 }
1377
1378 pub fn emission(mut self, emission: ThemeRuntimeEmission) -> Self {
1379 self.emission = emission;
1380 self
1381 }
1382
1383 pub fn serialization(mut self, serialization: ThemeSerializationFormat) -> Self {
1384 self.serialization = serialization;
1385 self
1386 }
1387
1388 pub fn diagnostics(mut self, diagnostics: ThemeDiagnosticVerbosity) -> Self {
1389 self.diagnostics = diagnostics;
1390 self
1391 }
1392
1393 pub fn fallback(mut self, fallback: ThemeFallbackStrategy) -> Self {
1394 self.fallback = fallback;
1395 self
1396 }
1397
1398 pub fn budget(mut self, budget: ThemeOutputBudget) -> Self {
1399 self.budget = budget;
1400 self
1401 }
1402
1403 pub fn label(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
1404 self.labels.insert(key.into(), value.into());
1405 self
1406 }
1407
1408 pub fn tag(mut self, tag: impl Into<String>) -> Self {
1409 let tag = tag.into();
1410 if !tag.is_empty() && !self.tags.contains(&tag) {
1411 self.tags.push(tag);
1412 self.tags.sort();
1413 }
1414 self
1415 }
1416}
1417
1418#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1419#[serde(rename_all = "camelCase")]
1420pub struct ThemeManifestFragment {
1421 pub package: String,
1422 pub version: String,
1423 #[serde(default, skip_serializing_if = "Option::is_none")]
1424 pub route: Option<String>,
1425 pub enabled: bool,
1426 pub cache_key: String,
1427 pub default_theme: String,
1428 pub runtime_path: String,
1429 pub profile: ThemePresetProfile,
1430 pub emission: ThemeRuntimeEmission,
1431 pub fallback: ThemeFallbackStrategy,
1432 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1433 pub labels: BTreeMap<String, String>,
1434 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1435 pub tags: Vec<String>,
1436 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1437 pub metrics: BTreeMap<String, u64>,
1438 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1439 pub policies: BTreeMap<String, serde_json::Value>,
1440}
1441
1442#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1443#[serde(rename_all = "camelCase")]
1444pub struct ThemeOutputViolation {
1445 pub field: String,
1446 pub actual: usize,
1447 pub budget: usize,
1448}
1449
1450#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1451#[serde(rename_all = "camelCase")]
1452pub struct ThemeOutputReport {
1453 pub package: String,
1454 #[serde(default, skip_serializing_if = "Option::is_none")]
1455 pub route: Option<String>,
1456 pub cache_key: String,
1457 pub config_bytes: usize,
1458 pub runtime_bytes: usize,
1459 pub style_bytes: usize,
1460 pub theme_count: usize,
1461 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1462 pub violations: Vec<ThemeOutputViolation>,
1463}
1464
1465impl ThemeOutputReport {
1466 pub fn is_within_budget(&self) -> bool {
1467 self.violations.is_empty()
1468 }
1469}
1470
1471#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1472#[serde(rename_all = "camelCase")]
1473pub struct ThemeExplainReport {
1474 pub package: String,
1475 #[serde(default, skip_serializing_if = "Option::is_none")]
1476 pub route: Option<String>,
1477 pub cache_key: String,
1478 pub runtime_decision: String,
1479 pub token_decision: String,
1480 pub fallback_decision: String,
1481 pub validation: ThemeValidationReport,
1482 pub manifest: ThemeManifestFragment,
1483 pub output: ThemeOutputReport,
1484 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1485 pub notes: Vec<String>,
1486}
1487
1488#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1489#[serde(rename_all = "camelCase")]
1490pub struct ThemeCompatibilityRow {
1491 pub target: String,
1492 pub support: String,
1493 pub runtime: String,
1494 pub fallback: String,
1495 pub notes: String,
1496}
1497
1498#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1499#[serde(rename_all = "camelCase")]
1500pub struct ThemeCompatibilityMatrix {
1501 pub package: String,
1502 pub rows: Vec<ThemeCompatibilityRow>,
1503}
1504
1505pub trait ThemeManifestPolicyHook {
1506 fn apply(&self, fragment: ThemeManifestFragment) -> Option<ThemeManifestFragment>;
1507}
1508
1509pub fn apply_theme_manifest_hook<H>(
1510 config: &ThemeConfig,
1511 policy: &ThemeRoutePolicy,
1512 hook: &H,
1513) -> Option<ThemeManifestFragment>
1514where
1515 H: ThemeManifestPolicyHook,
1516{
1517 hook.apply(theme_manifest_fragment(config, policy))
1518}
1519
1520pub fn theme_route_policy() -> ThemeRoutePolicy {
1521 ThemeRoutePolicy::new()
1522}
1523
1524pub fn theme_output_budget() -> ThemeOutputBudget {
1525 ThemeOutputBudget::new()
1526}
1527
1528pub fn theme_cache_key(config: &ThemeConfig, route: Option<&str>, extra: Option<&str>) -> String {
1529 let json = config.to_json().unwrap_or_default();
1530 stable_hash_hex([
1531 THEME_PACKAGE_NAME,
1532 THEME_PACKAGE_VERSION,
1533 route.unwrap_or("*"),
1534 extra.unwrap_or(""),
1535 json.as_str(),
1536 ])
1537}
1538
1539pub fn theme_manifest_fragment(
1540 config: &ThemeConfig,
1541 policy: &ThemeRoutePolicy,
1542) -> ThemeManifestFragment {
1543 let output = theme_output_report(config, policy);
1544 let mut metrics = BTreeMap::new();
1545 metrics.insert("configBytes".to_string(), output.config_bytes as u64);
1546 metrics.insert("runtimeBytes".to_string(), output.runtime_bytes as u64);
1547 metrics.insert("styleBytes".to_string(), output.style_bytes as u64);
1548 metrics.insert("themeCount".to_string(), output.theme_count as u64);
1549 let mut policies = BTreeMap::new();
1550 policies.insert(
1551 "interop".to_string(),
1552 serde_json::to_value(&policy.interop).unwrap_or(serde_json::Value::Null),
1553 );
1554 policies.insert(
1555 "route".to_string(),
1556 serde_json::json!({
1557 "enabled": policy.enabled,
1558 "profile": policy.profile,
1559 "emission": policy.emission,
1560 "serialization": policy.serialization,
1561 "fallback": policy.fallback,
1562 }),
1563 );
1564
1565 ThemeManifestFragment {
1566 package: THEME_PACKAGE_NAME.to_string(),
1567 version: THEME_PACKAGE_VERSION.to_string(),
1568 route: policy.route.clone(),
1569 enabled: policy.enabled,
1570 cache_key: output.cache_key,
1571 default_theme: config.default_theme.clone(),
1572 runtime_path: config.runtime_path.clone(),
1573 profile: policy.profile,
1574 emission: policy.emission,
1575 fallback: policy.fallback,
1576 labels: policy.labels.clone(),
1577 tags: policy.tags.clone(),
1578 metrics,
1579 policies,
1580 }
1581}
1582
1583pub fn theme_output_report(config: &ThemeConfig, policy: &ThemeRoutePolicy) -> ThemeOutputReport {
1584 let config_json = config
1585 .to_preferred_json(policy.serialization)
1586 .unwrap_or_default();
1587 let runtime_bytes = if policy.enabled
1588 && !matches!(
1589 policy.emission,
1590 ThemeRuntimeEmission::Disabled | ThemeRuntimeEmission::PrepaintOnly
1591 ) {
1592 config.runtime_path.len()
1593 } else {
1594 0
1595 };
1596 let style_bytes = config
1597 .registry
1598 .themes
1599 .iter()
1600 .map(|theme| theme_tokens_css(theme).len())
1601 .sum::<usize>();
1602 let theme_count = config.registry.themes.len();
1603 let mut violations = Vec::new();
1604 push_theme_budget_violation(
1605 &mut violations,
1606 "configBytes",
1607 config_json.len(),
1608 policy.budget.max_config_bytes,
1609 );
1610 push_theme_budget_violation(
1611 &mut violations,
1612 "runtimeBytes",
1613 runtime_bytes,
1614 policy.budget.max_runtime_bytes,
1615 );
1616 push_theme_budget_violation(
1617 &mut violations,
1618 "styleBytes",
1619 style_bytes,
1620 policy.budget.max_style_bytes,
1621 );
1622 push_theme_budget_violation(
1623 &mut violations,
1624 "themeCount",
1625 theme_count,
1626 policy.budget.max_theme_count,
1627 );
1628
1629 ThemeOutputReport {
1630 package: THEME_PACKAGE_NAME.to_string(),
1631 route: policy.route.clone(),
1632 cache_key: theme_cache_key(
1633 config,
1634 policy.route.as_deref(),
1635 Some(policy.profile.as_attr()),
1636 ),
1637 config_bytes: config_json.len(),
1638 runtime_bytes,
1639 style_bytes,
1640 theme_count,
1641 violations,
1642 }
1643}
1644
1645pub fn explain_theme(config: &ThemeConfig, policy: &ThemeRoutePolicy) -> ThemeExplainReport {
1646 let validation = config.validate();
1647 let output = theme_output_report(config, policy);
1648 let manifest = theme_manifest_fragment(config, policy);
1649 let runtime_decision = if !policy.enabled {
1650 "route disabled theme emission".to_string()
1651 } else if policy.emission == ThemeRuntimeEmission::Disabled {
1652 "theme runtime disabled by route policy".to_string()
1653 } else if policy.emission == ThemeRuntimeEmission::PrepaintOnly {
1654 "only prepaint CSS and data attributes should be emitted".to_string()
1655 } else {
1656 "theme runtime emitted with resumable handlers and storage policy".to_string()
1657 };
1658 let token_decision = format!(
1659 "{} themes produce {} bytes of token CSS",
1660 output.theme_count, output.style_bytes
1661 );
1662 let fallback_decision = format!("fallback strategy: {:?}", policy.fallback);
1663 let mut notes = Vec::new();
1664 if !validation.is_valid() {
1665 notes.push("validation errors must be resolved before SSR emission".to_string());
1666 }
1667 if policy.interop.hoverfx {
1668 notes.push("HoverFX can consume theme CSS custom properties".to_string());
1669 }
1670 if policy.interop.textfx {
1671 notes.push("TextFX gradients can reference theme visual tokens".to_string());
1672 }
1673 if !output.is_within_budget() {
1674 notes.push("one or more theme output budgets were exceeded".to_string());
1675 }
1676
1677 ThemeExplainReport {
1678 package: THEME_PACKAGE_NAME.to_string(),
1679 route: policy.route.clone(),
1680 cache_key: output.cache_key.clone(),
1681 runtime_decision,
1682 token_decision,
1683 fallback_decision,
1684 validation,
1685 manifest,
1686 output,
1687 notes,
1688 }
1689}
1690
1691pub fn theme_compatibility_matrix() -> ThemeCompatibilityMatrix {
1692 ThemeCompatibilityMatrix {
1693 package: THEME_PACKAGE_NAME.to_string(),
1694 rows: vec![
1695 ThemeCompatibilityRow {
1696 target: "web".to_string(),
1697 support: "full".to_string(),
1698 runtime: "prepaint CSS plus module runtime".to_string(),
1699 fallback: "system-theme".to_string(),
1700 notes: "ViewTX, HoverFX, and TextFX can consume shared theme policy".to_string(),
1701 },
1702 ThemeCompatibilityRow {
1703 target: "server".to_string(),
1704 support: "manifest".to_string(),
1705 runtime: "route-gated config/style/runtime emission".to_string(),
1706 fallback: "static-tokens".to_string(),
1707 notes: "resume/Strata consumers can use manifest fragments and cache keys"
1708 .to_string(),
1709 },
1710 ThemeCompatibilityRow {
1711 target: "native".to_string(),
1712 support: "adapter".to_string(),
1713 runtime: "native-port theme actions".to_string(),
1714 fallback: "native-port".to_string(),
1715 notes: "native renderers can consume theme ids and visual token manifests"
1716 .to_string(),
1717 },
1718 ThemeCompatibilityRow {
1719 target: "cli".to_string(),
1720 support: "report".to_string(),
1721 runtime: "none".to_string(),
1722 fallback: "stable-json".to_string(),
1723 notes: "budget reports track config, style, runtime bytes, and theme counts"
1724 .to_string(),
1725 },
1726 ],
1727 }
1728}
1729
1730pub fn theme_native_port_hints(
1731 config: &ThemeConfig,
1732 policy: &ThemeRoutePolicy,
1733) -> BTreeMap<String, String> {
1734 let mut hints = BTreeMap::new();
1735 hints.insert("package".to_string(), THEME_PACKAGE_NAME.to_string());
1736 hints.insert("version".to_string(), THEME_PACKAGE_VERSION.to_string());
1737 hints.insert(
1738 "cacheKey".to_string(),
1739 theme_cache_key(config, policy.route.as_deref(), None),
1740 );
1741 hints.insert(
1742 "route".to_string(),
1743 policy.route.clone().unwrap_or_else(|| "*".to_string()),
1744 );
1745 hints.insert("runtime".to_string(), policy.emission.as_attr().to_string());
1746 hints.insert("profile".to_string(), policy.profile.as_attr().to_string());
1747 hints.insert("defaultTheme".to_string(), config.default_theme.clone());
1748 hints.insert(
1749 "themeCount".to_string(),
1750 config.registry.themes.len().to_string(),
1751 );
1752 hints
1753}
1754
1755fn push_theme_budget_violation(
1756 violations: &mut Vec<ThemeOutputViolation>,
1757 field: &str,
1758 actual: usize,
1759 budget: Option<usize>,
1760) {
1761 if let Some(budget) = budget
1762 && actual > budget
1763 {
1764 violations.push(ThemeOutputViolation {
1765 field: field.to_string(),
1766 actual,
1767 budget,
1768 });
1769 }
1770}
1771
1772fn stable_hash_hex<'a>(parts: impl IntoIterator<Item = &'a str>) -> String {
1773 let mut hash = 0xcbf29ce484222325u64;
1774 for part in parts {
1775 for byte in part.as_bytes() {
1776 hash ^= u64::from(*byte);
1777 hash = hash.wrapping_mul(0x100000001b3);
1778 }
1779 hash ^= 0xff;
1780 hash = hash.wrapping_mul(0x100000001b3);
1781 }
1782 format!("{hash:016x}")
1783}
1784
1785impl std::ops::Add<ThemeDefinition> for ThemeConfig {
1786 type Output = Self;
1787
1788 fn add(self, rhs: ThemeDefinition) -> Self::Output {
1789 self.with_theme(rhs)
1790 }
1791}
1792
1793pub mod prelude {
1794 pub use crate::integration::*;
1795 pub use crate::{
1796 ThemeAnim, ThemeAnimationMode, ThemeCfg, ThemeColorScheme, ThemeCompatibilityMatrix,
1797 ThemeCompatibilityRow, ThemeConfig, ThemeDef, ThemeDefinition, ThemeDiagnosticVerbosity,
1798 ThemeExplainReport, ThemeFallbackStrategy, ThemeInteropPolicy, ThemeManifestFragment,
1799 ThemeManifestPolicyHook, ThemeOutputBudget, ThemeOutputReport, ThemeOutputViolation,
1800 ThemePreset, ThemePresetProfile, ThemeReducedMotion, ThemeReg, ThemeRegistry,
1801 ThemeRoutePolicy, ThemeRuntimeEmission, ThemeSerializationFormat, ThemeVisualTokenRole,
1802 apply_theme_manifest_hook, default_themes, explain_theme, theme, theme_cache_key,
1803 theme_compatibility_matrix, theme_def, theme_id, theme_manifest_fragment,
1804 theme_native_port_hints, theme_output_budget, theme_output_report, theme_route_policy,
1805 themes,
1806 };
1807}
1808
1809pub fn theme_id(id: impl AsRef<str>) -> String {
1810 let mut output = String::new();
1811 for ch in id.as_ref().chars() {
1812 if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' {
1813 output.push(ch.to_ascii_lowercase());
1814 } else if ch.is_whitespace() || matches!(ch, '.' | ':' | '/') {
1815 output.push('-');
1816 }
1817 }
1818 let output = output.trim_matches('-');
1819 if output.is_empty() {
1820 "theme".to_string()
1821 } else {
1822 output.to_string()
1823 }
1824}
1825
1826pub fn is_custom_property_name(name: &str) -> bool {
1827 let Some(rest) = name.strip_prefix("--") else {
1828 return false;
1829 };
1830 !rest.is_empty()
1831 && rest
1832 .chars()
1833 .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_'))
1834}
1835
1836pub fn is_valid_theme_target(target: &str) -> bool {
1837 let trimmed = target.trim();
1838 matches!(trimmed, "html" | ":root")
1839 || (!trimmed.is_empty()
1840 && trimmed
1841 .chars()
1842 .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.' | '#')))
1843}
1844
1845pub fn is_valid_theme_attribute(attribute: &str) -> bool {
1846 let trimmed = attribute.trim();
1847 !trimmed.is_empty()
1848 && trimmed
1849 .chars()
1850 .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | ':'))
1851}
1852
1853pub fn normalize_animation_speed(speed: u16) -> u16 {
1854 speed.clamp(MIN_THEME_ANIMATION_SPEED, MAX_THEME_ANIMATION_SPEED)
1855}
1856
1857pub fn theme_tokens_css(theme: &ThemeDefinition) -> String {
1858 let mut css = String::new();
1859 css.push_str("color-scheme:");
1860 css.push_str(theme.color_scheme.as_css());
1861 css.push(';');
1862 for (name, value) in &theme.tokens {
1863 if is_custom_property_name(name) && is_safe_css_token_value(value) {
1864 css.push_str(name);
1865 css.push(':');
1866 css.push_str(value);
1867 css.push(';');
1868 }
1869 }
1870 css
1871}
1872
1873pub fn is_safe_css_token_value(value: &str) -> bool {
1874 !value.trim().is_empty()
1875 && !value
1876 .chars()
1877 .any(|ch| ch.is_control() || matches!(ch, ';' | '{' | '}' | '<' | '>' | '`'))
1878}
1879
1880#[cfg(test)]
1881mod tests {
1882 use super::*;
1883
1884 #[test]
1885 fn registry_defaults_include_light_dark_system() {
1886 let registry = ThemeRegistry::default();
1887 assert!(registry.contains_theme("light"));
1888 assert!(registry.contains_theme("dark"));
1889 assert!(registry.contains_theme("system"));
1890 }
1891
1892 #[test]
1893 fn theme_ids_are_sanitized() {
1894 assert_eq!(theme_id("High Contrast"), "high-contrast");
1895 assert_eq!(theme_id(""), "theme");
1896 assert_eq!(theme_id("../Dark Mode"), "dark-mode");
1897 }
1898
1899 #[test]
1900 fn duplicate_theme_replaces_existing_definition() {
1901 let registry = ThemeRegistry::new()
1902 .with_theme(ThemeDefinition::new("brand", "Brand"))
1903 .with_theme(ThemeDefinition::new("brand", "Updated"));
1904 assert_eq!(registry.themes.len(), 1);
1905 assert_eq!(registry.theme("brand").unwrap().label, "Updated");
1906 }
1907
1908 #[test]
1909 fn token_css_contains_valid_custom_properties() {
1910 let theme = ThemeDefinition::new("brand", "Brand")
1911 .with_color_scheme(ThemeColorScheme::Dark)
1912 .with_token("--brand-bg", "#000")
1913 .with_token("--bad-value", "red;}body{display:none")
1914 .with_token("bad", "#fff");
1915 let css = theme_tokens_css(&theme);
1916 assert!(css.contains("color-scheme:dark;"));
1917 assert!(css.contains("--brand-bg:#000;"));
1918 assert!(!css.contains("--bad-value"));
1919 assert!(!css.contains("bad:#fff"));
1920 }
1921
1922 #[test]
1923 fn visual_token_helpers_write_canonical_theme_tokens() {
1924 let theme = ThemeDefinition::new("brand", "Brand")
1925 .with_visual_token(ThemeVisualTokenRole::Background, "#101010")
1926 .with_visual_tokens([
1927 (ThemeVisualTokenRole::Text, "#f8fafc"),
1928 (ThemeVisualTokenRole::Accent, "#22d3ee"),
1929 ]);
1930
1931 assert_eq!(
1932 theme.tokens.get(THEME_TOKEN_BG).map(String::as_str),
1933 Some("#101010")
1934 );
1935 assert_eq!(
1936 theme.tokens.get(THEME_TOKEN_FG).map(String::as_str),
1937 Some("#f8fafc")
1938 );
1939 assert_eq!(
1940 theme.tokens.get(THEME_TOKEN_ACCENT).map(String::as_str),
1941 Some("#22d3ee")
1942 );
1943 assert!(theme_tokens_css(&theme).contains("--dxt-accent:#22d3ee;"));
1944 assert_eq!(
1945 theme_visual_token_css_var("surface-border"),
1946 Some(THEME_TOKEN_SURFACE_BORDER)
1947 );
1948 assert_eq!(
1949 theme_visual_token_css_var("primary"),
1950 Some(THEME_TOKEN_ACCENT)
1951 );
1952 assert_eq!(theme_visual_token_css_var("unknown"), None);
1953 }
1954
1955 #[test]
1956 fn visual_token_manifest_is_stable_and_serializable() {
1957 let manifest = theme_visual_token_manifest();
1958 assert_eq!(manifest.version, THEME_VISUAL_TOKEN_MANIFEST_VERSION);
1959 assert_eq!(manifest.change_event, THEME_CHANGE_EVENT);
1960 assert_eq!(manifest.tokens.len(), 6);
1961 assert_eq!(ThemeVisualTokenRole::Accent.css_var(), THEME_TOKEN_ACCENT);
1962 assert_eq!(ThemeVisualTokenRole::Surface.js_key(), "surface");
1963 assert_eq!(THEME_TOKEN_TEXT, THEME_TOKEN_FG);
1964 assert_eq!(THEME_TOKEN_SURFACE, THEME_TOKEN_PANEL);
1965
1966 let json = theme_visual_token_manifest_json().expect("manifest serializes");
1967 let cached = theme_visual_token_manifest_json().expect("manifest serializes again");
1968 assert_eq!(json, cached);
1969 assert!(json.contains("\"changeEvent\":\"dioxus-theme:change\""));
1970 assert!(json.contains("\"key\":\"surfaceBorder\""));
1971 assert!(json.contains("\"cssVar\":\"--dxt-accent\""));
1972 }
1973
1974 #[test]
1975 fn compact_config_omits_default_scalar_values() {
1976 let default = ThemeConfig::default();
1977 let full = default.to_json().expect("full config serializes");
1978 let compact = default
1979 .to_compact_json()
1980 .expect("compact config serializes");
1981 assert!(compact.len() < full.len());
1982 assert!(compact.contains("\"registry\""));
1983 assert!(!compact.contains("\"storageKey\""));
1984 assert!(!compact.contains("\"animationPreset\""));
1985
1986 let custom = default
1987 .with_storage_key("brand-theme")
1988 .with_duration_ms(140);
1989 let custom_compact = custom
1990 .to_compact_json()
1991 .expect("custom compact config serializes");
1992 assert!(custom_compact.contains("\"storageKey\":\"brand-theme\""));
1993 assert!(custom_compact.contains("\"durationMs\":140"));
1994 }
1995
1996 #[test]
1997 fn config_serializes_camel_case_overrides() {
1998 let json = ThemeConfig::default()
1999 .with_storage_key("custom-theme")
2000 .with_animation_storage_key("custom-animation")
2001 .with_animation_preset(ThemeAnimationPreset::MaskedWave)
2002 .with_animation_speed(175)
2003 .with_animation_speed_storage_key("custom-animation-speed")
2004 .with_view_transition_name_isolation(false)
2005 .with_easing("linear")
2006 .with_default_theme("dark")
2007 .with_duration_ms(120)
2008 .to_json()
2009 .expect("config serializes");
2010 assert!(json.contains("\"storageKey\":\"custom-theme\""));
2011 assert!(json.contains("\"animationStorageKey\":\"custom-animation\""));
2012 assert!(json.contains("\"animationPreset\":\"masked-wave\""));
2013 assert!(json.contains("\"animationSpeed\":175"));
2014 assert!(json.contains("\"animationSpeedStorageKey\":\"custom-animation-speed\""));
2015 assert!(json.contains("\"isolateViewTransitionNames\":false"));
2016 assert!(json.contains("\"easing\":\"linear\""));
2017 assert!(json.contains("\"defaultTheme\":\"dark\""));
2018 assert!(json.contains("\"durationMs\":120"));
2019 }
2020
2021 #[test]
2022 fn view_transition_name_isolation_defaults_on() {
2023 let config = ThemeConfig::default();
2024 assert!(config.isolate_view_transition_names);
2025 assert!(
2026 config
2027 .to_json()
2028 .expect("config serializes")
2029 .contains("\"isolateViewTransitionNames\":true")
2030 );
2031 }
2032
2033 #[test]
2034 fn animation_presets_are_stable_and_kebab_case() {
2035 assert_eq!(
2036 ThemeAnimationPreset::default(),
2037 ThemeAnimationPreset::CrossFade
2038 );
2039 assert_eq!(ThemeAnimationPreset::all().len(), 5);
2040 assert_eq!(ThemeAnimationPreset::MaskedWave.as_attr(), "masked-wave");
2041 let json =
2042 serde_json::to_string(&ThemeAnimationPreset::RadialWipe).expect("preset serializes");
2043 assert_eq!(json, "\"radial-wipe\"");
2044 }
2045
2046 #[test]
2047 fn animation_speed_is_clamped() {
2048 assert_eq!(
2049 ThemeConfig::default()
2050 .with_animation_speed(0)
2051 .animation_speed,
2052 MIN_THEME_ANIMATION_SPEED
2053 );
2054 assert_eq!(
2055 ThemeConfig::default()
2056 .with_animation_speed(500)
2057 .animation_speed,
2058 MAX_THEME_ANIMATION_SPEED
2059 );
2060 }
2061
2062 #[test]
2063 fn validation_accepts_defaults_and_reports_bad_overrides() {
2064 assert!(ThemeConfig::default().validate().is_valid());
2065
2066 let mut invalid = ThemeConfig::default()
2067 .with_default_theme("missing")
2068 .with_storage_key("")
2069 .with_animation_storage_key("")
2070 .with_animation_speed_storage_key("")
2071 .with_target("html body")
2072 .with_attribute("");
2073 invalid.registry.themes[0]
2074 .tokens
2075 .insert("bad".to_string(), "red".to_string());
2076 invalid.registry.themes[0]
2077 .tokens
2078 .insert("--unsafe".to_string(), "red;}body{display:none".to_string());
2079
2080 let report = invalid.validate();
2081 assert!(!report.is_valid());
2082 assert!(report.errors().count() >= 7);
2083 assert!(
2084 report
2085 .issues
2086 .iter()
2087 .any(|issue| issue.code == ThemeValidationCode::MissingDefaultTheme)
2088 );
2089 assert!(
2090 report
2091 .issues
2092 .iter()
2093 .any(|issue| issue.code == ThemeValidationCode::UnsafeTokenValue)
2094 );
2095 }
2096
2097 #[test]
2098 fn short_theme_builders_match_long_form_config() {
2099 let custom = theme_def("brand", "Brand")
2100 .scheme(ThemeColorScheme::Dark)
2101 .token(THEME_TOKEN_BG, "#111111");
2102 let config = theme()
2103 .add(custom)
2104 .default_theme("brand")
2105 .dur_ms(140)
2106 .ease("linear")
2107 .reduced(ThemeReducedMotion::Ignore)
2108 .preset(ThemeAnimationPreset::RadialWipe)
2109 .speed(180);
2110
2111 assert_eq!(config.default_theme, "brand");
2112 assert_eq!(config.duration_ms, 140);
2113 assert_eq!(config.easing, "linear");
2114 assert_eq!(config.reduced_motion, ThemeReducedMotion::Ignore);
2115 assert_eq!(config.animation_preset, ThemeAnimationPreset::RadialWipe);
2116 assert_eq!(config.animation_speed, 180);
2117 assert!(config.registry.ids().contains(&"brand"));
2118 }
2119
2120 #[test]
2121 fn route_policy_manifest_and_budget_report_track_theme_output() {
2122 let config = theme()
2123 .route_profile(ThemePresetProfile::Expressive)
2124 .theme(
2125 theme_def("brand", "Brand")
2126 .scheme(ThemeColorScheme::Dark)
2127 .token(THEME_TOKEN_ACCENT, "#22d3ee"),
2128 )
2129 .default_theme("brand");
2130 let policy = theme_route_policy()
2131 .route("/theme")
2132 .profile(ThemePresetProfile::Expressive)
2133 .emission(ThemeRuntimeEmission::PrepaintOnly)
2134 .serialization(ThemeSerializationFormat::CompactJson)
2135 .budget(theme_output_budget().config_bytes(4).theme_count(8))
2136 .label("owner", "design-system")
2137 .tag("tokens");
2138
2139 let manifest = config.manifest_fragment(&policy);
2140 let report = config.output_report(&policy);
2141 let hints = theme_native_port_hints(&config, &policy);
2142
2143 assert_eq!(manifest.package, THEME_PACKAGE_NAME);
2144 assert_eq!(manifest.route.as_deref(), Some("/theme"));
2145 assert_eq!(manifest.profile, ThemePresetProfile::Expressive);
2146 assert_eq!(manifest.emission, ThemeRuntimeEmission::PrepaintOnly);
2147 assert_eq!(manifest.metrics["themeCount"], report.theme_count as u64);
2148 assert_eq!(report.runtime_bytes, 0);
2149 assert!(
2150 report
2151 .violations
2152 .iter()
2153 .any(|violation| violation.field == "configBytes")
2154 );
2155 assert_eq!(hints["defaultTheme"], "brand");
2156 assert_eq!(
2157 config.cache_key(Some("/theme")),
2158 config.cache_key(Some("/theme"))
2159 );
2160 }
2161
2162 #[test]
2163 fn explain_report_matrix_and_hook_cover_visual_interop() {
2164 struct DropDisabled;
2165
2166 impl ThemeManifestPolicyHook for DropDisabled {
2167 fn apply(&self, fragment: ThemeManifestFragment) -> Option<ThemeManifestFragment> {
2168 fragment.enabled.then_some(fragment)
2169 }
2170 }
2171
2172 let config = ThemeConfig::default();
2173 let enabled_policy = theme_route_policy().route("/theme").tag("hoverfx");
2174 let disabled_policy = theme_route_policy()
2175 .route("/theme/off")
2176 .enabled(false)
2177 .emission(ThemeRuntimeEmission::Disabled);
2178 let explain = explain_theme(&config, &enabled_policy);
2179 let matrix = theme_compatibility_matrix();
2180
2181 assert!(explain.validation.is_valid());
2182 assert!(explain.notes.iter().any(|note| note.contains("HoverFX")));
2183 assert!(matrix.rows.iter().any(|row| row.target == "native"));
2184 assert!(apply_theme_manifest_hook(&config, &enabled_policy, &DropDisabled).is_some());
2185 assert!(apply_theme_manifest_hook(&config, &disabled_policy, &DropDisabled).is_none());
2186 }
2187}