Skip to main content

dioprism_theme/
lib.rs

1extern crate self as dioprism_theme;
2
3pub mod core;
4#[cfg(any(feature = "server", feature = "ssr"))]
5pub mod ssr;
6
7pub use dioprism_theme::core::{
8    DEFAULT_THEME_ANIMATION_SPEED, DEFAULT_THEME_ANIMATION_SPEED_STORAGE_KEY,
9    DEFAULT_THEME_ANIMATION_STORAGE_KEY, DEFAULT_THEME_ATTRIBUTE, DEFAULT_THEME_DURATION_MS,
10    DEFAULT_THEME_EASING, DEFAULT_THEME_RUNTIME_BASE_PATH, DEFAULT_THEME_RUNTIME_PATH,
11    DEFAULT_THEME_RUNTIME_VERSION, DEFAULT_THEME_STORAGE_KEY, DEFAULT_THEME_TARGET,
12    MAX_THEME_ANIMATION_SPEED, MIN_THEME_ANIMATION_SPEED, THEME_CHANGE_EVENT, THEME_TOKEN_ACCENT,
13    THEME_TOKEN_BACKGROUND, THEME_TOKEN_BG, THEME_TOKEN_FG, THEME_TOKEN_MUTED, THEME_TOKEN_PANEL,
14    THEME_TOKEN_PANEL_BORDER, THEME_TOKEN_SURFACE, THEME_TOKEN_SURFACE_BORDER, THEME_TOKEN_TEXT,
15    THEME_VISUAL_TOKEN_MANIFEST_VERSION, THEME_VISUAL_TOKENS, ThemeAnim, ThemeAnimationMode,
16    ThemeAnimationPreset, ThemeCfg, ThemeColorScheme, ThemeConfig, ThemeDef, ThemeDefinition,
17    ThemeMotion, ThemePreset, ThemeReducedMotion, ThemeReg, ThemeRegistry, ThemeValidationCode,
18    ThemeValidationIssue, ThemeValidationReport, ThemeValidationSeverity,
19    ThemeVisualTokenDefinition, ThemeVisualTokenManifest, ThemeVisualTokenRole, default_themes,
20    is_safe_css_token_value, is_valid_theme_attribute, is_valid_theme_target,
21    normalize_animation_speed, theme, theme_def, theme_id, theme_tokens_css,
22    theme_visual_token_css_var, theme_visual_token_manifest, theme_visual_token_manifest_json,
23    themes,
24};
25use dioxus::prelude::*;
26use std::sync::OnceLock;
27
28pub use ThemeAnimationSelect as AnimSelect;
29pub use ThemeAnimationSpeedSlider as SpeedSlider;
30pub use ThemeProvider as Theme;
31pub use ThemeSelect as Select;
32pub use ThemeToggle as Toggle;
33
34pub mod prelude {
35    pub use dioprism_theme::core::prelude::*;
36
37    pub use crate::{
38        AnimSelect, Select, SpeedSlider, Theme, ThemeAnim, ThemeAnimationMode,
39        ThemeAnimationPreset, ThemeCfg, ThemeColorScheme, ThemeConfig, ThemeDef, ThemeDefinition,
40        ThemePreset, ThemeProvider, ThemeReducedMotion, ThemeReg, ThemeRegistry, ThemeSelect,
41        ThemeToggle, Toggle, default_themes, theme, theme_component_explain,
42        theme_component_manifest, theme_def, theme_id, theme_native_integration_hints, themes,
43    };
44}
45
46pub mod dx {
47    pub use crate::prelude::*;
48    pub use dioprism_motion::core::dx::DurationDx;
49    pub use dioprism_theme::core::ThemeMotion;
50
51    pub type MotionPreset = dioprism_theme::core::ThemeAnimationPreset;
52
53    pub fn theme_cfg() -> dioprism_theme::core::ThemeConfig {
54        dioprism_theme::core::ThemeConfig::new()
55    }
56
57    pub fn theme(id: impl AsRef<str>) -> dioprism_theme::core::ThemeDefinition {
58        let id = id.as_ref();
59        dioprism_theme::core::ThemeDefinition::new(id, id)
60    }
61
62    pub fn motion() -> dioprism_theme::core::ThemeMotion {
63        dioprism_theme::core::ThemeMotion::new()
64    }
65
66    pub trait ThemeDefinitionDx {
67        fn dark(self) -> Self;
68        fn light(self) -> Self;
69    }
70
71    impl ThemeDefinitionDx for dioprism_theme::core::ThemeDefinition {
72        fn dark(self) -> Self {
73            self.with_color_scheme(dioprism_theme::core::ThemeColorScheme::Dark)
74        }
75
76        fn light(self) -> Self {
77            self.with_color_scheme(dioprism_theme::core::ThemeColorScheme::Light)
78        }
79    }
80
81    pub trait ThemeConfigDx {
82        fn default(self, theme: impl AsRef<str>) -> Self;
83    }
84
85    impl ThemeConfigDx for dioprism_theme::core::ThemeConfig {
86        fn default(self, theme: impl AsRef<str>) -> Self {
87            self.with_default_theme(theme)
88        }
89    }
90}
91
92pub fn theme_component_manifest(
93    config: &ThemeConfig,
94    policy: &dioprism_theme::core::ThemeRoutePolicy,
95) -> dioprism_theme::core::ThemeManifestFragment {
96    config.manifest_fragment(policy)
97}
98
99pub fn theme_component_explain(
100    config: &ThemeConfig,
101    policy: &dioprism_theme::core::ThemeRoutePolicy,
102) -> dioprism_theme::core::ThemeExplainReport {
103    config.explain(policy)
104}
105
106pub fn theme_native_integration_hints(
107    config: &ThemeConfig,
108    policy: &dioprism_theme::core::ThemeRoutePolicy,
109) -> std::collections::BTreeMap<String, String> {
110    let mut hints = dioprism_theme::core::theme_native_port_hints(config, policy);
111    hints.insert(
112        "nativeActions".to_string(),
113        theme_native_package_actions(policy.route.as_deref())
114            .len()
115            .to_string(),
116    );
117    hints.insert(
118        "nativePackage".to_string(),
119        theme_native_compatibility_manifest().package,
120    );
121    hints.insert(
122        "visualTokenCount".to_string(),
123        theme_visual_token_manifest().tokens.len().to_string(),
124    );
125    hints
126}
127
128#[derive(Clone, Copy, Debug, Eq, PartialEq)]
129pub enum ThemeRuntimeMode {
130    BrowserRuntime,
131    StaticFallback,
132}
133
134pub fn theme_runtime_mode(config: &ThemeConfig) -> ThemeRuntimeMode {
135    if cfg!(all(feature = "web", target_arch = "wasm32")) && config.animation.is_animated() {
136        ThemeRuntimeMode::BrowserRuntime
137    } else {
138        ThemeRuntimeMode::StaticFallback
139    }
140}
141
142pub fn theme_native_fallback_config() -> ThemeConfig {
143    ThemeConfig::default().with_animation(ThemeAnimationMode::CssOnly)
144}
145
146pub fn theme_native_compatibility_manifest() -> dioprism_native_port::VisualCompatibilityManifest {
147    static MANIFEST: OnceLock<dioprism_native_port::VisualCompatibilityManifest> = OnceLock::new();
148    MANIFEST
149        .get_or_init(|| {
150            dioprism_native_port::native_port_visual_compatibility_manifest("dioprism-theme")
151                .expect("dioprism-theme visual compatibility manifest is registered")
152        })
153        .clone()
154}
155
156#[derive(Clone, Copy, Debug, Eq, PartialEq)]
157pub enum ThemeNativeAction {
158    ToggleTheme,
159    SetTheme,
160    CycleTheme,
161    SetAnimationPreset,
162    SetAnimationSpeed,
163}
164
165impl ThemeNativeAction {
166    pub const fn as_str(self) -> &'static str {
167        match self {
168            Self::ToggleTheme => "toggle-theme",
169            Self::SetTheme => "set-theme",
170            Self::CycleTheme => "cycle-theme",
171            Self::SetAnimationPreset => "set-animation-preset",
172            Self::SetAnimationSpeed => "set-animation-speed",
173        }
174    }
175
176    pub const fn label(self) -> &'static str {
177        match self {
178            Self::ToggleTheme => "Toggle theme",
179            Self::SetTheme => "Set theme",
180            Self::CycleTheme => "Cycle theme",
181            Self::SetAnimationPreset => "Set animation preset",
182            Self::SetAnimationSpeed => "Set animation speed",
183        }
184    }
185}
186
187pub fn theme_native_package_actions(
188    route: Option<&str>,
189) -> Vec<dioprism_native_port::NativePackageAction> {
190    let route = route.map(str::to_string);
191    [
192        ThemeNativeAction::ToggleTheme,
193        ThemeNativeAction::SetTheme,
194        ThemeNativeAction::CycleTheme,
195        ThemeNativeAction::SetAnimationPreset,
196        ThemeNativeAction::SetAnimationSpeed,
197    ]
198    .into_iter()
199    .map(|action| {
200        let mut package_action = dioprism_native_port::NativePackageAction::new(
201            "dioprism-theme",
202            action.as_str(),
203            action.label(),
204            dioprism_native_port::NativeActionKind::NativeAction,
205        )
206        .description("Applies a configured theme without reloading the page.");
207        if let Some(route) = route.clone() {
208            package_action = package_action.route(route);
209        }
210        package_action
211    })
212    .collect()
213}
214
215pub fn theme_native_action(
216    config: &ThemeConfig,
217    action: ThemeNativeAction,
218    current_theme: impl Into<String>,
219) -> dioprism_native_port::NativeActionResult {
220    let current_theme = theme_id(current_theme.into());
221    let next_theme = match action {
222        ThemeNativeAction::ToggleTheme | ThemeNativeAction::CycleTheme => {
223            config.toggle_theme_id(&current_theme)
224        }
225        ThemeNativeAction::SetTheme => current_theme.clone(),
226        ThemeNativeAction::SetAnimationPreset | ThemeNativeAction::SetAnimationSpeed => {
227            current_theme.clone()
228        }
229    };
230    let mode = theme_runtime_mode(config);
231    let backend = match mode {
232        ThemeRuntimeMode::BrowserRuntime => "browser-runtime",
233        ThemeRuntimeMode::StaticFallback => "static-fallback",
234    };
235
236    dioprism_native_port::NativeActionResult::succeeded(
237        "dioprism-theme",
238        action.as_str(),
239        dioprism_native_port::NativeActionKind::NativeAction,
240        format!("{} prepared `{next_theme}`", action.label()),
241    )
242    .with_backend(backend)
243    .with_output("currentTheme", current_theme)
244    .with_output("nextTheme", next_theme)
245    .with_output("storageKey", config.storage_key.clone())
246    .with_output("animation", config.animation.as_attr())
247    .with_output("animationPreset", config.animation_preset.as_attr())
248    .with_output("animationStorageKey", config.animation_storage_key.clone())
249    .with_output("animationSpeed", config.animation_speed.to_string())
250    .with_output(
251        "animationSpeedStorageKey",
252        config.animation_speed_storage_key.clone(),
253    )
254    .with_output("themeCount", config.registry.themes.len().to_string())
255}
256
257pub fn use_theme(config: ThemeConfig) -> dioprism_native_port::PortableStorage {
258    let key = config.storage_key.clone();
259    let default_theme = config.default_theme.clone();
260    dioprism_native_port::use_portable_storage(key, move || default_theme)
261}
262
263fn theme_control_id(prefix: &str, handler: &str, label: &str) -> String {
264    format!("{prefix}-{}", theme_id(format!("{handler}-{label}")))
265}
266
267#[derive(Props, Clone, PartialEq)]
268pub struct ThemeProviderProps {
269    #[props(default)]
270    pub config: ThemeConfig,
271    #[props(default)]
272    pub class: String,
273    pub children: Element,
274}
275
276#[component]
277pub fn ThemeProvider(props: ThemeProviderProps) -> Element {
278    let default_theme = props.config.default_theme.clone();
279    let storage_key = props.config.storage_key.clone();
280    rsx! {
281        div {
282            class: "{props.class}",
283            "data-dxt-provider": "true",
284            "data-dxt-default-theme": "{default_theme}",
285            "data-dxt-storage-key": "{storage_key}",
286            {props.children}
287        }
288    }
289}
290
291#[derive(Props, Clone, PartialEq)]
292pub struct ThemeToggleProps {
293    #[props(default = "theme.toggle".to_string())]
294    pub handler: String,
295    #[props(default = "Toggle theme".to_string())]
296    pub label: String,
297    #[props(default)]
298    pub class: String,
299    #[props(default)]
300    pub next_theme: String,
301}
302
303#[component]
304pub fn ThemeToggle(props: ThemeToggleProps) -> Element {
305    let next_theme = theme_id(&props.next_theme);
306    rsx! {
307        button {
308            r#type: "button",
309            class: "{props.class}",
310            "aria-label": "{props.label}",
311            "data-dxr-on-click": "{props.handler}",
312            "data-dxt-theme-next": "{next_theme}",
313            "data-dxt-theme-control": "toggle",
314            span {
315                "data-dxt-theme-toggle-label": "true",
316                "aria-live": "polite",
317                "{props.label}"
318            }
319        }
320    }
321}
322
323#[derive(Props, Clone, PartialEq)]
324pub struct ThemeSelectProps {
325    #[props(default)]
326    pub config: ThemeConfig,
327    #[props(default = "theme.set".to_string())]
328    pub handler: String,
329    #[props(default)]
330    pub class: String,
331    #[props(default = "Theme".to_string())]
332    pub label: String,
333}
334
335#[component]
336pub fn ThemeSelect(props: ThemeSelectProps) -> Element {
337    let select_id = theme_control_id("dxt-theme-select", &props.handler, &props.label);
338    let label_id = format!("{select_id}-label");
339    let current_id = format!("{select_id}-current");
340    let current_label = props
341        .config
342        .resolve_theme(&props.config.default_theme)
343        .map(|theme| theme.label.clone())
344        .unwrap_or_else(|| props.config.default_theme.clone());
345    rsx! {
346        label {
347            class: "{props.class}",
348            "data-dxt-theme-control": "select",
349            "for": "{select_id}",
350            span {
351                id: "{label_id}",
352                "{props.label}"
353            }
354            select {
355                id: "{select_id}",
356                "aria-labelledby": "{label_id} {current_id}",
357                "data-dxr-on-change": "{props.handler}",
358                "data-dxt-theme-select": "true",
359                for theme in props.config.registry.themes.iter() {
360                    option {
361                        value: "{theme.id}",
362                        "{theme.label}"
363                    }
364                }
365            }
366            span {
367                id: "{current_id}",
368                "aria-live": "polite",
369                "data-dxt-theme-current": "true",
370                "{current_label}"
371            }
372        }
373    }
374}
375
376#[derive(Props, Clone, PartialEq)]
377pub struct ThemeAnimationSelectProps {
378    #[props(default)]
379    pub config: ThemeConfig,
380    #[props(default = "theme.animation".to_string())]
381    pub handler: String,
382    #[props(default)]
383    pub class: String,
384    #[props(default = "Animation".to_string())]
385    pub label: String,
386}
387
388#[component]
389pub fn ThemeAnimationSelect(props: ThemeAnimationSelectProps) -> Element {
390    let current = props.config.animation_preset.as_attr();
391    let select_id = theme_control_id("dxt-theme-animation", &props.handler, &props.label);
392    let label_id = format!("{select_id}-label");
393    let current_id = format!("{select_id}-current");
394    let current_label = props.config.animation_preset.label();
395    rsx! {
396        label {
397            class: "{props.class}",
398            "data-dxt-theme-control": "animation-select",
399            "for": "{select_id}",
400            span {
401                id: "{label_id}",
402                "{props.label}"
403            }
404            select {
405                id: "{select_id}",
406                value: "{current}",
407                "aria-labelledby": "{label_id} {current_id}",
408                "data-dxr-on-change": "{props.handler}",
409                "data-dxt-theme-animation-select": "true",
410                for preset in ThemeAnimationPreset::all().iter().copied() {
411                    option {
412                        value: "{preset.as_attr()}",
413                        selected: preset == props.config.animation_preset,
414                        "{preset.label()}"
415                    }
416                }
417            }
418            span {
419                id: "{current_id}",
420                "aria-live": "polite",
421                "data-dxt-theme-animation-current": "true",
422                "{current_label}"
423            }
424        }
425    }
426}
427
428#[derive(Props, Clone, PartialEq)]
429pub struct ThemeAnimationSpeedSliderProps {
430    #[props(default)]
431    pub config: ThemeConfig,
432    #[props(default = "theme.animation-speed".to_string())]
433    pub handler: String,
434    #[props(default)]
435    pub class: String,
436    #[props(default = "Animation speed".to_string())]
437    pub label: String,
438    #[props(default = MIN_THEME_ANIMATION_SPEED)]
439    pub min: u16,
440    #[props(default = MAX_THEME_ANIMATION_SPEED)]
441    pub max: u16,
442    #[props(default = 25)]
443    pub step: u16,
444}
445
446#[component]
447pub fn ThemeAnimationSpeedSlider(props: ThemeAnimationSpeedSliderProps) -> Element {
448    let value = normalize_animation_speed(props.config.animation_speed);
449    let min = normalize_animation_speed(props.min);
450    let max = normalize_animation_speed(props.max);
451    let step = props.step.max(1);
452    let input_id = theme_control_id("dxt-theme-animation-speed", &props.handler, &props.label);
453    let output_id = format!("{input_id}-output");
454    rsx! {
455        label {
456            class: "{props.class}",
457            "data-dxt-theme-control": "animation-speed",
458            "for": "{input_id}",
459            span {
460                "{props.label}: "
461                output {
462                    id: "{output_id}",
463                    "for": "{input_id}",
464                    "aria-live": "polite",
465                    "data-dxt-theme-animation-speed-current": "true",
466                    "{value}%"
467                }
468            }
469            input {
470                id: "{input_id}",
471                r#type: "range",
472                min: "{min}",
473                max: "{max}",
474                step: "{step}",
475                value: "{value}",
476                "aria-describedby": "{output_id}",
477                "data-dxr-on-input": "{props.handler}",
478                "data-dxt-theme-animation-speed": "true"
479            }
480        }
481    }
482}
483
484#[cfg(test)]
485mod tests {
486    use super::*;
487
488    #[test]
489    fn native_action_reports_next_theme() {
490        let config = ThemeConfig::default().with_default_theme("dark");
491        let result = theme_native_action(&config, ThemeNativeAction::ToggleTheme, "dark");
492        assert_eq!(
493            result.outputs.get("currentTheme"),
494            Some(&"dark".to_string())
495        );
496        assert!(result.outputs.contains_key("nextTheme"));
497        assert_eq!(
498            result.outputs.get("animationPreset"),
499            Some(&"cross-fade".to_string())
500        );
501        assert_eq!(
502            result.outputs.get("animationSpeed"),
503            Some(&"100".to_string())
504        );
505
506        let actions = theme_native_package_actions(Some("/browser"));
507        assert!(
508            actions
509                .iter()
510                .any(|action| action.action == "set-animation-preset")
511        );
512        assert!(
513            actions
514                .iter()
515                .any(|action| action.action == "set-animation-speed")
516        );
517    }
518
519    #[test]
520    fn visual_token_contract_is_reexported() {
521        let manifest = theme_visual_token_manifest();
522        let native_manifest = theme_native_compatibility_manifest();
523        let cached_native_manifest = theme_native_compatibility_manifest();
524        assert_eq!(THEME_CHANGE_EVENT, "dioprism-theme:change");
525        assert_eq!(manifest.tokens.len(), THEME_VISUAL_TOKENS.len());
526        assert_eq!(native_manifest.package, "dioprism-theme");
527        assert_eq!(native_manifest, cached_native_manifest);
528        assert_eq!(ThemeVisualTokenRole::Accent.css_var(), THEME_TOKEN_ACCENT);
529        assert_eq!(THEME_TOKEN_SURFACE_BORDER, THEME_TOKEN_PANEL_BORDER);
530    }
531
532    #[test]
533    fn component_manifest_wraps_core_policy_and_native_hints() {
534        let config = ThemeConfig::default();
535        let policy = dioprism_theme::core::theme_route_policy()
536            .route("/theme")
537            .tag("native");
538        let manifest = theme_component_manifest(&config, &policy);
539        let explain = theme_component_explain(&config, &policy);
540        let hints = theme_native_integration_hints(&config, &policy);
541
542        assert_eq!(manifest.route.as_deref(), Some("/theme"));
543        assert_eq!(explain.manifest.cache_key, manifest.cache_key);
544        assert_eq!(hints["nativePackage"], "dioprism-theme");
545        assert_eq!(
546            hints["visualTokenCount"],
547            theme_visual_token_manifest().tokens.len().to_string()
548        );
549        assert!(hints["nativeActions"].parse::<usize>().unwrap() >= 1);
550    }
551
552    #[test]
553    fn dx_theme_syntax_builds_namespaced_config() {
554        use crate::dx::{DurationDx, ThemeConfigDx, ThemeDefinitionDx};
555
556        let config = crate::dx::theme_cfg()
557            .theme(crate::dx::theme("brand").label("Brand").dark())
558            .default("brand")
559            .motion(
560                crate::dx::motion()
561                    .dur(140.ms())
562                    .preset(crate::dx::MotionPreset::RadialWipe),
563            );
564
565        let brand = config
566            .registry
567            .themes
568            .iter()
569            .find(|theme| theme.id == "brand")
570            .expect("brand theme is registered");
571
572        assert_eq!(config.default_theme, "brand");
573        assert_eq!(config.duration_ms, 140);
574        assert_eq!(config.animation_preset, ThemeAnimationPreset::RadialWipe);
575        assert_eq!(brand.label, "Brand");
576        assert_eq!(brand.color_scheme, ThemeColorScheme::Dark);
577    }
578}