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(¤t_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}