Skip to main content

dioxus_theme_core/
lib.rs

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}