use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
pub const DEFAULT_THEME_RUNTIME_BASE_PATH: &str = "/assets/dioxus-theme.js";
pub const DEFAULT_THEME_RUNTIME_VERSION: &str = "1";
pub const DEFAULT_THEME_RUNTIME_PATH: &str = "/assets/dioxus-theme.js?v=1";
pub const DEFAULT_THEME_STORAGE_KEY: &str = "dioxus-theme";
pub const DEFAULT_THEME_ATTRIBUTE: &str = "data-dxt-theme";
pub const DEFAULT_THEME_TARGET: &str = "html";
pub const DEFAULT_THEME_DURATION_MS: u32 = 220;
pub const DEFAULT_THEME_EASING: &str = "ease-in-out";
pub const DEFAULT_THEME_ANIMATION_STORAGE_KEY: &str = "dioxus-theme-animation";
pub const DEFAULT_THEME_ANIMATION_SPEED: u16 = 100;
pub const DEFAULT_THEME_ANIMATION_SPEED_STORAGE_KEY: &str = "dioxus-theme-animation-speed";
pub const MIN_THEME_ANIMATION_SPEED: u16 = 25;
pub const MAX_THEME_ANIMATION_SPEED: u16 = 300;
pub const THEME_TOKEN_BG: &str = "--dxt-bg";
pub const THEME_TOKEN_FG: &str = "--dxt-fg";
pub const THEME_TOKEN_MUTED: &str = "--dxt-muted";
pub const THEME_TOKEN_PANEL: &str = "--dxt-panel";
pub const THEME_TOKEN_PANEL_BORDER: &str = "--dxt-panel-border";
pub const THEME_TOKEN_ACCENT: &str = "--dxt-accent";
pub const THEME_TOKEN_BACKGROUND: &str = THEME_TOKEN_BG;
pub const THEME_TOKEN_TEXT: &str = THEME_TOKEN_FG;
pub const THEME_TOKEN_SURFACE: &str = THEME_TOKEN_PANEL;
pub const THEME_TOKEN_SURFACE_BORDER: &str = THEME_TOKEN_PANEL_BORDER;
pub const THEME_CHANGE_EVENT: &str = "dioxus-theme:change";
pub const THEME_VISUAL_TOKEN_MANIFEST_VERSION: u8 = 1;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum ThemeVisualTokenRole {
Background,
Text,
Muted,
Surface,
SurfaceBorder,
Accent,
}
impl ThemeVisualTokenRole {
pub const fn as_attr(self) -> &'static str {
match self {
Self::Background => "background",
Self::Text => "text",
Self::Muted => "muted",
Self::Surface => "surface",
Self::SurfaceBorder => "surface-border",
Self::Accent => "accent",
}
}
pub const fn js_key(self) -> &'static str {
match self {
Self::Background => "background",
Self::Text => "text",
Self::Muted => "muted",
Self::Surface => "surface",
Self::SurfaceBorder => "surfaceBorder",
Self::Accent => "accent",
}
}
pub const fn css_var(self) -> &'static str {
match self {
Self::Background => THEME_TOKEN_BACKGROUND,
Self::Text => THEME_TOKEN_TEXT,
Self::Muted => THEME_TOKEN_MUTED,
Self::Surface => THEME_TOKEN_SURFACE,
Self::SurfaceBorder => THEME_TOKEN_SURFACE_BORDER,
Self::Accent => THEME_TOKEN_ACCENT,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ThemeVisualTokenDefinition {
pub role: ThemeVisualTokenRole,
pub key: &'static str,
pub css_var: &'static str,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ThemeVisualTokenManifest {
pub version: u8,
pub change_event: &'static str,
pub tokens: &'static [ThemeVisualTokenDefinition],
}
pub const THEME_VISUAL_TOKENS: [ThemeVisualTokenDefinition; 6] = [
ThemeVisualTokenDefinition {
role: ThemeVisualTokenRole::Background,
key: ThemeVisualTokenRole::Background.js_key(),
css_var: THEME_TOKEN_BACKGROUND,
},
ThemeVisualTokenDefinition {
role: ThemeVisualTokenRole::Text,
key: ThemeVisualTokenRole::Text.js_key(),
css_var: THEME_TOKEN_TEXT,
},
ThemeVisualTokenDefinition {
role: ThemeVisualTokenRole::Muted,
key: ThemeVisualTokenRole::Muted.js_key(),
css_var: THEME_TOKEN_MUTED,
},
ThemeVisualTokenDefinition {
role: ThemeVisualTokenRole::Surface,
key: ThemeVisualTokenRole::Surface.js_key(),
css_var: THEME_TOKEN_SURFACE,
},
ThemeVisualTokenDefinition {
role: ThemeVisualTokenRole::SurfaceBorder,
key: ThemeVisualTokenRole::SurfaceBorder.js_key(),
css_var: THEME_TOKEN_SURFACE_BORDER,
},
ThemeVisualTokenDefinition {
role: ThemeVisualTokenRole::Accent,
key: ThemeVisualTokenRole::Accent.js_key(),
css_var: THEME_TOKEN_ACCENT,
},
];
pub const fn theme_visual_token_manifest() -> ThemeVisualTokenManifest {
ThemeVisualTokenManifest {
version: THEME_VISUAL_TOKEN_MANIFEST_VERSION,
change_event: THEME_CHANGE_EVENT,
tokens: &THEME_VISUAL_TOKENS,
}
}
pub fn theme_visual_token_manifest_json() -> Result<String, serde_json::Error> {
serde_json::to_string(&theme_visual_token_manifest())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum ThemeColorScheme {
Light,
Dark,
System,
Normal,
}
impl Default for ThemeColorScheme {
fn default() -> Self {
Self::System
}
}
impl ThemeColorScheme {
pub fn as_css(self) -> &'static str {
match self {
Self::Light => "light",
Self::Dark => "dark",
Self::System => "light dark",
Self::Normal => "normal",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum ThemeAnimationMode {
ViewTransition,
CssOnly,
None,
}
impl Default for ThemeAnimationMode {
fn default() -> Self {
Self::ViewTransition
}
}
impl ThemeAnimationMode {
pub fn as_attr(self) -> &'static str {
match self {
Self::ViewTransition => "view-transition",
Self::CssOnly => "css-only",
Self::None => "none",
}
}
pub fn is_animated(self) -> bool {
!matches!(self, Self::None)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum ThemeAnimationPreset {
Fade,
CrossFade,
Slide,
RadialWipe,
MaskedWave,
}
impl Default for ThemeAnimationPreset {
fn default() -> Self {
Self::CrossFade
}
}
impl ThemeAnimationPreset {
pub const fn all() -> &'static [Self; 5] {
&[
Self::Fade,
Self::CrossFade,
Self::Slide,
Self::RadialWipe,
Self::MaskedWave,
]
}
pub const fn as_attr(self) -> &'static str {
match self {
Self::Fade => "fade",
Self::CrossFade => "cross-fade",
Self::Slide => "slide",
Self::RadialWipe => "radial-wipe",
Self::MaskedWave => "masked-wave",
}
}
pub const fn label(self) -> &'static str {
match self {
Self::Fade => "Fade",
Self::CrossFade => "Cross fade",
Self::Slide => "Slide",
Self::RadialWipe => "Radial wipe",
Self::MaskedWave => "Masked wave",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum ThemeReducedMotion {
Respect,
Ignore,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum ThemeValidationSeverity {
Error,
Warning,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum ThemeValidationCode {
EmptyStorageKey,
EmptyAnimationStorageKey,
EmptyAnimationSpeedStorageKey,
MissingDefaultTheme,
MissingSystemLightTheme,
MissingSystemDarkTheme,
InvalidTarget,
InvalidAttribute,
InvalidTokenName,
UnsafeTokenValue,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ThemeValidationIssue {
pub severity: ThemeValidationSeverity,
pub code: ThemeValidationCode,
pub message: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub field: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub theme: Option<String>,
}
impl ThemeValidationIssue {
pub fn error(
code: ThemeValidationCode,
field: impl Into<String>,
message: impl Into<String>,
) -> Self {
Self {
severity: ThemeValidationSeverity::Error,
code,
message: message.into(),
field: Some(field.into()),
theme: None,
}
}
pub fn token_error(
code: ThemeValidationCode,
theme: impl Into<String>,
field: impl Into<String>,
message: impl Into<String>,
) -> Self {
Self {
severity: ThemeValidationSeverity::Error,
code,
message: message.into(),
field: Some(field.into()),
theme: Some(theme.into()),
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ThemeValidationReport {
pub issues: Vec<ThemeValidationIssue>,
}
impl ThemeValidationReport {
pub fn is_valid(&self) -> bool {
self.issues
.iter()
.all(|issue| issue.severity != ThemeValidationSeverity::Error)
}
pub fn errors(&self) -> impl Iterator<Item = &ThemeValidationIssue> {
self.issues
.iter()
.filter(|issue| issue.severity == ThemeValidationSeverity::Error)
}
pub fn warnings(&self) -> impl Iterator<Item = &ThemeValidationIssue> {
self.issues
.iter()
.filter(|issue| issue.severity == ThemeValidationSeverity::Warning)
}
pub fn push(&mut self, issue: ThemeValidationIssue) {
self.issues.push(issue);
}
}
impl Default for ThemeReducedMotion {
fn default() -> Self {
Self::Respect
}
}
impl ThemeReducedMotion {
pub fn as_attr(self) -> &'static str {
match self {
Self::Respect => "respect",
Self::Ignore => "ignore",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ThemeDefinition {
pub id: String,
pub label: String,
#[serde(default)]
pub color_scheme: ThemeColorScheme,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub tokens: BTreeMap<String, String>,
}
impl ThemeDefinition {
pub fn new(id: impl AsRef<str>, label: impl Into<String>) -> Self {
Self {
id: theme_id(id),
label: label.into(),
color_scheme: ThemeColorScheme::System,
tokens: BTreeMap::new(),
}
}
pub fn light() -> Self {
Self::new("light", "Light")
.with_color_scheme(ThemeColorScheme::Light)
.with_token(THEME_TOKEN_BG, "#f8fafc")
.with_token(THEME_TOKEN_FG, "#0f172a")
.with_token(THEME_TOKEN_MUTED, "#475569")
.with_token(THEME_TOKEN_PANEL, "#ffffff")
.with_token(THEME_TOKEN_PANEL_BORDER, "rgba(15,23,42,0.12)")
.with_token(THEME_TOKEN_ACCENT, "#0891b2")
}
pub fn dark() -> Self {
Self::new("dark", "Dark")
.with_color_scheme(ThemeColorScheme::Dark)
.with_token(THEME_TOKEN_BG, "#020617")
.with_token(THEME_TOKEN_FG, "#f8fafc")
.with_token(THEME_TOKEN_MUTED, "#cbd5e1")
.with_token(THEME_TOKEN_PANEL, "rgba(15,23,42,0.74)")
.with_token(THEME_TOKEN_PANEL_BORDER, "rgba(255,255,255,0.10)")
.with_token(THEME_TOKEN_ACCENT, "#22d3ee")
}
pub fn system() -> Self {
Self::new("system", "System").with_color_scheme(ThemeColorScheme::System)
}
pub fn with_label(mut self, label: impl Into<String>) -> Self {
self.label = label.into();
self
}
pub fn with_color_scheme(mut self, color_scheme: ThemeColorScheme) -> Self {
self.color_scheme = color_scheme;
self
}
pub fn with_token(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
let name = name.into();
if is_custom_property_name(&name) {
self.tokens.insert(name, value.into());
}
self
}
pub fn with_tokens<I, K, V>(mut self, tokens: I) -> Self
where
I: IntoIterator<Item = (K, V)>,
K: Into<String>,
V: Into<String>,
{
for (name, value) in tokens {
let name = name.into();
if is_custom_property_name(&name) {
self.tokens.insert(name, value.into());
}
}
self
}
pub fn is_system(&self) -> bool {
self.id == "system"
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ThemeRegistry {
pub themes: Vec<ThemeDefinition>,
}
impl Default for ThemeRegistry {
fn default() -> Self {
Self::defaults()
}
}
impl ThemeRegistry {
pub fn new() -> Self {
Self { themes: Vec::new() }
}
pub fn defaults() -> Self {
Self::new()
.with_theme(ThemeDefinition::light())
.with_theme(ThemeDefinition::dark())
.with_theme(ThemeDefinition::system())
}
pub fn with_theme(mut self, theme: ThemeDefinition) -> Self {
self.insert_theme(theme);
self
}
pub fn insert_theme(&mut self, theme: ThemeDefinition) -> Option<ThemeDefinition> {
if let Some(existing) = self
.themes
.iter_mut()
.find(|candidate| candidate.id == theme.id)
{
return Some(std::mem::replace(existing, theme));
}
self.themes.push(theme);
None
}
pub fn contains_theme(&self, id: impl AsRef<str>) -> bool {
let id = theme_id(id);
self.themes.iter().any(|theme| theme.id == id)
}
pub fn theme(&self, id: impl AsRef<str>) -> Option<&ThemeDefinition> {
let id = theme_id(id);
self.themes.iter().find(|theme| theme.id == id)
}
pub fn theme_ids(&self) -> Vec<&str> {
self.themes.iter().map(|theme| theme.id.as_str()).collect()
}
pub fn first_non_system_theme(&self) -> Option<&ThemeDefinition> {
self.themes.iter().find(|theme| !theme.is_system())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ThemeConfig {
pub registry: ThemeRegistry,
pub default_theme: String,
pub system_light_theme: String,
pub system_dark_theme: String,
pub storage_key: String,
pub attribute: String,
pub target: String,
pub duration_ms: u32,
pub easing: String,
pub reduced_motion: ThemeReducedMotion,
pub animation: ThemeAnimationMode,
pub animation_preset: ThemeAnimationPreset,
pub animation_storage_key: String,
pub animation_speed: u16,
pub animation_speed_storage_key: String,
pub isolate_view_transition_names: bool,
pub runtime_path: String,
}
impl Default for ThemeConfig {
fn default() -> Self {
Self::new()
}
}
impl ThemeConfig {
pub fn new() -> Self {
Self {
registry: ThemeRegistry::default(),
default_theme: "system".to_string(),
system_light_theme: "light".to_string(),
system_dark_theme: "dark".to_string(),
storage_key: DEFAULT_THEME_STORAGE_KEY.to_string(),
attribute: DEFAULT_THEME_ATTRIBUTE.to_string(),
target: DEFAULT_THEME_TARGET.to_string(),
duration_ms: DEFAULT_THEME_DURATION_MS,
easing: DEFAULT_THEME_EASING.to_string(),
reduced_motion: ThemeReducedMotion::Respect,
animation: ThemeAnimationMode::ViewTransition,
animation_preset: ThemeAnimationPreset::CrossFade,
animation_storage_key: DEFAULT_THEME_ANIMATION_STORAGE_KEY.to_string(),
animation_speed: DEFAULT_THEME_ANIMATION_SPEED,
animation_speed_storage_key: DEFAULT_THEME_ANIMATION_SPEED_STORAGE_KEY.to_string(),
isolate_view_transition_names: true,
runtime_path: DEFAULT_THEME_RUNTIME_PATH.to_string(),
}
}
pub fn with_registry(mut self, registry: ThemeRegistry) -> Self {
self.registry = registry;
self
}
pub fn with_theme(mut self, theme: ThemeDefinition) -> Self {
self.registry.insert_theme(theme);
self
}
pub fn with_default_theme(mut self, theme: impl AsRef<str>) -> Self {
self.default_theme = theme_id(theme);
self
}
pub fn with_system_theme(
mut self,
light_theme: impl AsRef<str>,
dark_theme: impl AsRef<str>,
) -> Self {
self.system_light_theme = theme_id(light_theme);
self.system_dark_theme = theme_id(dark_theme);
self
}
pub fn with_storage_key(mut self, storage_key: impl Into<String>) -> Self {
self.storage_key = storage_key.into();
self
}
pub fn with_attribute(mut self, attribute: impl Into<String>) -> Self {
self.attribute = attribute.into();
self
}
pub fn with_target(mut self, target: impl Into<String>) -> Self {
self.target = target.into();
self
}
pub fn with_duration_ms(mut self, duration_ms: u32) -> Self {
self.duration_ms = duration_ms;
self
}
pub fn with_easing(mut self, easing: impl Into<String>) -> Self {
self.easing = easing.into();
self
}
pub fn with_reduced_motion(mut self, reduced_motion: ThemeReducedMotion) -> Self {
self.reduced_motion = reduced_motion;
self
}
#[cfg(feature = "viewtx")]
pub fn with_viewtx_timing(mut self, config: &dioxus_viewtx_core::ViewTransitionConfig) -> Self {
self.duration_ms = config.duration_ms;
self.easing = config.easing.clone();
self.reduced_motion = match config.reduced_motion {
dioxus_viewtx_core::ViewTransitionReducedMotion::Ignore => ThemeReducedMotion::Ignore,
dioxus_viewtx_core::ViewTransitionReducedMotion::Disable
| dioxus_viewtx_core::ViewTransitionReducedMotion::FadeOnly => {
ThemeReducedMotion::Respect
}
};
self
}
#[cfg(feature = "viewtx")]
pub fn with_viewtx_motion_policy(
mut self,
policy: &dioxus_viewtx_core::ViewMotionPolicy,
) -> Self {
self.duration_ms = policy.duration_ms;
self.easing = policy.easing.clone();
self.reduced_motion = match policy.reduced_motion {
dioxus_viewtx_core::ViewTransitionReducedMotion::Ignore => ThemeReducedMotion::Ignore,
dioxus_viewtx_core::ViewTransitionReducedMotion::Disable
| dioxus_viewtx_core::ViewTransitionReducedMotion::FadeOnly => {
ThemeReducedMotion::Respect
}
};
self.isolate_view_transition_names = policy.isolate_view_transition_names();
self
}
pub fn with_animation(mut self, animation: ThemeAnimationMode) -> Self {
self.animation = animation;
self
}
pub fn with_animation_preset(mut self, animation_preset: ThemeAnimationPreset) -> Self {
self.animation_preset = animation_preset;
self
}
pub fn with_animation_storage_key(mut self, animation_storage_key: impl Into<String>) -> Self {
self.animation_storage_key = animation_storage_key.into();
self
}
pub fn with_animation_speed(mut self, animation_speed: u16) -> Self {
self.animation_speed = normalize_animation_speed(animation_speed);
self
}
pub fn with_animation_speed_storage_key(
mut self,
animation_speed_storage_key: impl Into<String>,
) -> Self {
self.animation_speed_storage_key = animation_speed_storage_key.into();
self
}
pub fn with_view_transition_name_isolation(mut self, isolate: bool) -> Self {
self.isolate_view_transition_names = isolate;
self
}
pub fn with_runtime_path(mut self, runtime_path: impl Into<String>) -> Self {
self.runtime_path = runtime_path.into();
self
}
pub fn validate(&self) -> ThemeValidationReport {
let mut report = ThemeValidationReport::default();
if self.storage_key.trim().is_empty() {
report.push(ThemeValidationIssue::error(
ThemeValidationCode::EmptyStorageKey,
"storage_key",
"theme storage key must not be empty",
));
}
if self.animation_storage_key.trim().is_empty() {
report.push(ThemeValidationIssue::error(
ThemeValidationCode::EmptyAnimationStorageKey,
"animation_storage_key",
"animation preset storage key must not be empty",
));
}
if self.animation_speed_storage_key.trim().is_empty() {
report.push(ThemeValidationIssue::error(
ThemeValidationCode::EmptyAnimationSpeedStorageKey,
"animation_speed_storage_key",
"animation speed storage key must not be empty",
));
}
if !self.registry.contains_theme(&self.default_theme) {
report.push(ThemeValidationIssue::error(
ThemeValidationCode::MissingDefaultTheme,
"default_theme",
format!("default theme `{}` is not registered", self.default_theme),
));
}
if !self.registry.contains_theme(&self.system_light_theme) {
report.push(ThemeValidationIssue::error(
ThemeValidationCode::MissingSystemLightTheme,
"system_light_theme",
format!(
"system light theme `{}` is not registered",
self.system_light_theme
),
));
}
if !self.registry.contains_theme(&self.system_dark_theme) {
report.push(ThemeValidationIssue::error(
ThemeValidationCode::MissingSystemDarkTheme,
"system_dark_theme",
format!(
"system dark theme `{}` is not registered",
self.system_dark_theme
),
));
}
if !is_valid_theme_target(&self.target) {
report.push(ThemeValidationIssue::error(
ThemeValidationCode::InvalidTarget,
"target",
"theme target must be html, :root, or a simple selector",
));
}
if !is_valid_theme_attribute(&self.attribute) {
report.push(ThemeValidationIssue::error(
ThemeValidationCode::InvalidAttribute,
"attribute",
"theme attribute must be a non-empty attribute name",
));
}
for theme in &self.registry.themes {
for (name, value) in &theme.tokens {
if !is_custom_property_name(name) {
report.push(ThemeValidationIssue::token_error(
ThemeValidationCode::InvalidTokenName,
theme.id.clone(),
name.clone(),
"theme token names must be CSS custom properties",
));
}
if !is_safe_css_token_value(value) {
report.push(ThemeValidationIssue::token_error(
ThemeValidationCode::UnsafeTokenValue,
theme.id.clone(),
name.clone(),
"theme token values must be non-empty safe CSS values",
));
}
}
}
report
}
pub fn resolve_theme(&self, id: impl AsRef<str>) -> Option<&ThemeDefinition> {
let id = theme_id(id);
self.registry
.theme(&id)
.or_else(|| self.registry.theme(&self.default_theme))
.or_else(|| self.registry.first_non_system_theme())
}
pub fn toggle_theme_id(&self, current: impl AsRef<str>) -> String {
let current = theme_id(current);
let default = if self.default_theme == "system" {
self.system_dark_theme.as_str()
} else {
self.default_theme.as_str()
};
if current == default {
self.registry
.themes
.iter()
.find(|theme| !theme.is_system() && theme.id != default)
.map(|theme| theme.id.clone())
.unwrap_or_else(|| default.to_string())
} else {
default.to_string()
}
}
pub fn to_json(&self) -> Result<String, serde_json::Error> {
serde_json::to_string(self)
}
}
pub fn theme_id(id: impl AsRef<str>) -> String {
let mut output = String::new();
for ch in id.as_ref().chars() {
if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' {
output.push(ch.to_ascii_lowercase());
} else if ch.is_whitespace() || matches!(ch, '.' | ':' | '/') {
output.push('-');
}
}
let output = output.trim_matches('-');
if output.is_empty() {
"theme".to_string()
} else {
output.to_string()
}
}
pub fn is_custom_property_name(name: &str) -> bool {
let Some(rest) = name.strip_prefix("--") else {
return false;
};
!rest.is_empty()
&& rest
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_'))
}
pub fn is_valid_theme_target(target: &str) -> bool {
let trimmed = target.trim();
matches!(trimmed, "html" | ":root")
|| (!trimmed.is_empty()
&& trimmed
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.' | '#')))
}
pub fn is_valid_theme_attribute(attribute: &str) -> bool {
let trimmed = attribute.trim();
!trimmed.is_empty()
&& trimmed
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | ':'))
}
pub fn normalize_animation_speed(speed: u16) -> u16 {
speed.clamp(MIN_THEME_ANIMATION_SPEED, MAX_THEME_ANIMATION_SPEED)
}
pub fn theme_tokens_css(theme: &ThemeDefinition) -> String {
let mut css = String::new();
css.push_str("color-scheme:");
css.push_str(theme.color_scheme.as_css());
css.push(';');
for (name, value) in &theme.tokens {
if is_custom_property_name(name) && is_safe_css_token_value(value) {
css.push_str(name);
css.push(':');
css.push_str(value);
css.push(';');
}
}
css
}
pub fn is_safe_css_token_value(value: &str) -> bool {
!value.trim().is_empty()
&& !value
.chars()
.any(|ch| ch.is_control() || matches!(ch, ';' | '{' | '}' | '<' | '>' | '`'))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn registry_defaults_include_light_dark_system() {
let registry = ThemeRegistry::default();
assert!(registry.contains_theme("light"));
assert!(registry.contains_theme("dark"));
assert!(registry.contains_theme("system"));
}
#[test]
fn theme_ids_are_sanitized() {
assert_eq!(theme_id("High Contrast"), "high-contrast");
assert_eq!(theme_id(""), "theme");
assert_eq!(theme_id("../Dark Mode"), "dark-mode");
}
#[test]
fn duplicate_theme_replaces_existing_definition() {
let registry = ThemeRegistry::new()
.with_theme(ThemeDefinition::new("brand", "Brand"))
.with_theme(ThemeDefinition::new("brand", "Updated"));
assert_eq!(registry.themes.len(), 1);
assert_eq!(registry.theme("brand").unwrap().label, "Updated");
}
#[test]
fn token_css_contains_valid_custom_properties() {
let theme = ThemeDefinition::new("brand", "Brand")
.with_color_scheme(ThemeColorScheme::Dark)
.with_token("--brand-bg", "#000")
.with_token("--bad-value", "red;}body{display:none")
.with_token("bad", "#fff");
let css = theme_tokens_css(&theme);
assert!(css.contains("color-scheme:dark;"));
assert!(css.contains("--brand-bg:#000;"));
assert!(!css.contains("--bad-value"));
assert!(!css.contains("bad:#fff"));
}
#[test]
fn visual_token_manifest_is_stable_and_serializable() {
let manifest = theme_visual_token_manifest();
assert_eq!(manifest.version, THEME_VISUAL_TOKEN_MANIFEST_VERSION);
assert_eq!(manifest.change_event, THEME_CHANGE_EVENT);
assert_eq!(manifest.tokens.len(), 6);
assert_eq!(ThemeVisualTokenRole::Accent.css_var(), THEME_TOKEN_ACCENT);
assert_eq!(ThemeVisualTokenRole::Surface.js_key(), "surface");
assert_eq!(THEME_TOKEN_TEXT, THEME_TOKEN_FG);
assert_eq!(THEME_TOKEN_SURFACE, THEME_TOKEN_PANEL);
let json = theme_visual_token_manifest_json().expect("manifest serializes");
assert!(json.contains("\"changeEvent\":\"dioxus-theme:change\""));
assert!(json.contains("\"key\":\"surfaceBorder\""));
assert!(json.contains("\"cssVar\":\"--dxt-accent\""));
}
#[test]
fn config_serializes_camel_case_overrides() {
let json = ThemeConfig::default()
.with_storage_key("custom-theme")
.with_animation_storage_key("custom-animation")
.with_animation_preset(ThemeAnimationPreset::MaskedWave)
.with_animation_speed(175)
.with_animation_speed_storage_key("custom-animation-speed")
.with_view_transition_name_isolation(false)
.with_easing("linear")
.with_default_theme("dark")
.with_duration_ms(120)
.to_json()
.expect("config serializes");
assert!(json.contains("\"storageKey\":\"custom-theme\""));
assert!(json.contains("\"animationStorageKey\":\"custom-animation\""));
assert!(json.contains("\"animationPreset\":\"masked-wave\""));
assert!(json.contains("\"animationSpeed\":175"));
assert!(json.contains("\"animationSpeedStorageKey\":\"custom-animation-speed\""));
assert!(json.contains("\"isolateViewTransitionNames\":false"));
assert!(json.contains("\"easing\":\"linear\""));
assert!(json.contains("\"defaultTheme\":\"dark\""));
assert!(json.contains("\"durationMs\":120"));
}
#[test]
fn view_transition_name_isolation_defaults_on() {
let config = ThemeConfig::default();
assert!(config.isolate_view_transition_names);
assert!(
config
.to_json()
.expect("config serializes")
.contains("\"isolateViewTransitionNames\":true")
);
}
#[test]
fn animation_presets_are_stable_and_kebab_case() {
assert_eq!(
ThemeAnimationPreset::default(),
ThemeAnimationPreset::CrossFade
);
assert_eq!(ThemeAnimationPreset::all().len(), 5);
assert_eq!(ThemeAnimationPreset::MaskedWave.as_attr(), "masked-wave");
let json =
serde_json::to_string(&ThemeAnimationPreset::RadialWipe).expect("preset serializes");
assert_eq!(json, "\"radial-wipe\"");
}
#[test]
fn animation_speed_is_clamped() {
assert_eq!(
ThemeConfig::default()
.with_animation_speed(0)
.animation_speed,
MIN_THEME_ANIMATION_SPEED
);
assert_eq!(
ThemeConfig::default()
.with_animation_speed(500)
.animation_speed,
MAX_THEME_ANIMATION_SPEED
);
}
#[test]
fn validation_accepts_defaults_and_reports_bad_overrides() {
assert!(ThemeConfig::default().validate().is_valid());
let mut invalid = ThemeConfig::default()
.with_default_theme("missing")
.with_storage_key("")
.with_animation_storage_key("")
.with_animation_speed_storage_key("")
.with_target("html body")
.with_attribute("");
invalid.registry.themes[0]
.tokens
.insert("bad".to_string(), "red".to_string());
invalid.registry.themes[0]
.tokens
.insert("--unsafe".to_string(), "red;}body{display:none".to_string());
let report = invalid.validate();
assert!(!report.is_valid());
assert!(report.errors().count() >= 7);
assert!(
report
.issues
.iter()
.any(|issue| issue.code == ThemeValidationCode::MissingDefaultTheme)
);
assert!(
report
.issues
.iter()
.any(|issue| issue.code == ThemeValidationCode::UnsafeTokenValue)
);
}
}