use std::{collections::BTreeMap, fmt, sync::OnceLock};
use serde::{Deserialize, Serialize};
use std::time::Duration;
mod integration;
pub use integration::*;
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 THEME_PACKAGE_NAME: &str = "dioxus-theme";
pub const THEME_PACKAGE_VERSION: &str = env!("CARGO_PKG_VERSION");
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 fn theme_visual_token_css_var(alias: impl AsRef<str>) -> Option<&'static str> {
match alias.as_ref().trim() {
"background" | "bg" | "canvas" => Some(THEME_TOKEN_BACKGROUND),
"text" | "fg" | "foreground" => Some(THEME_TOKEN_TEXT),
"muted" | "subtle" => Some(THEME_TOKEN_MUTED),
"surface" | "panel" => Some(THEME_TOKEN_SURFACE),
"surface-border" | "panel-border" | "border" => Some(THEME_TOKEN_SURFACE_BORDER),
"accent" | "primary" => Some(THEME_TOKEN_ACCENT),
_ => None,
}
}
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> {
static MANIFEST_JSON: OnceLock<String> = OnceLock::new();
Ok(MANIFEST_JSON
.get_or_init(|| {
serde_json::to_string(&theme_visual_token_manifest())
.expect("theme visual token manifest serializes")
})
.clone())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
#[derive(Default)]
pub enum ThemeColorScheme {
Light,
Dark,
#[default]
System,
Normal,
}
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")]
#[derive(Default)]
pub enum ThemeAnimationMode {
#[default]
ViewTransition,
CssOnly,
None,
}
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")]
#[derive(Default)]
pub enum ThemeAnimationPreset {
Fade,
#[default]
CrossFade,
Slide,
RadialWipe,
MaskedWave,
}
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")]
#[derive(Default)]
pub enum ThemeReducedMotion {
#[default]
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 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_visual_token(ThemeVisualTokenRole::Background, "#f8fafc")
.with_visual_token(ThemeVisualTokenRole::Text, "#0f172a")
.with_visual_token(ThemeVisualTokenRole::Muted, "#475569")
.with_visual_token(ThemeVisualTokenRole::Surface, "#ffffff")
.with_visual_token(ThemeVisualTokenRole::SurfaceBorder, "rgba(15,23,42,0.12)")
.with_visual_token(ThemeVisualTokenRole::Accent, "#0891b2")
}
pub fn dark() -> Self {
Self::new("dark", "Dark")
.with_color_scheme(ThemeColorScheme::Dark)
.with_visual_token(ThemeVisualTokenRole::Background, "#020617")
.with_visual_token(ThemeVisualTokenRole::Text, "#f8fafc")
.with_visual_token(ThemeVisualTokenRole::Muted, "#cbd5e1")
.with_visual_token(ThemeVisualTokenRole::Surface, "rgba(15,23,42,0.74)")
.with_visual_token(
ThemeVisualTokenRole::SurfaceBorder,
"rgba(255,255,255,0.10)",
)
.with_visual_token(ThemeVisualTokenRole::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 label(self, label: impl Into<String>) -> Self {
self.with_label(label)
}
pub fn with_color_scheme(mut self, color_scheme: ThemeColorScheme) -> Self {
self.color_scheme = color_scheme;
self
}
pub fn scheme(self, color_scheme: ThemeColorScheme) -> Self {
self.with_color_scheme(color_scheme)
}
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 token(self, name: impl Into<String>, value: impl Into<String>) -> Self {
self.with_token(name, value)
}
pub fn with_visual_token(self, role: ThemeVisualTokenRole, value: impl Into<String>) -> Self {
self.with_token(role.css_var(), value)
}
pub fn visual(self, role: ThemeVisualTokenRole, value: impl Into<String>) -> Self {
self.with_visual_token(role, value)
}
pub fn with_visual_tokens<I, V>(mut self, tokens: I) -> Self
where
I: IntoIterator<Item = (ThemeVisualTokenRole, V)>,
V: Into<String>,
{
for (role, value) in tokens {
self = self.with_visual_token(role, value);
}
self
}
pub fn visuals<I, V>(self, tokens: I) -> Self
where
I: IntoIterator<Item = (ThemeVisualTokenRole, V)>,
V: Into<String>,
{
self.with_visual_tokens(tokens)
}
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 tokens<I, K, V>(self, tokens: I) -> Self
where
I: IntoIterator<Item = (K, V)>,
K: Into<String>,
V: Into<String>,
{
self.with_tokens(tokens)
}
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
}
#[allow(clippy::should_implement_trait)]
pub fn add(self, theme: ThemeDefinition) -> Self {
self.with_theme(theme)
}
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 ids(&self) -> Vec<&str> {
self.theme_ids()
}
pub fn first_non_system_theme(&self) -> Option<&ThemeDefinition> {
self.themes.iter().find(|theme| !theme.is_system())
}
}
impl std::ops::Add<ThemeDefinition> for ThemeRegistry {
type Output = Self;
fn add(self, rhs: ThemeDefinition) -> Self::Output {
self.with_theme(rhs)
}
}
pub type ThemeCfg = ThemeConfig;
pub type ThemeDef = ThemeDefinition;
pub type ThemeReg = ThemeRegistry;
pub type ThemeAnim = ThemeAnimationMode;
pub type ThemePreset = ThemeAnimationPreset;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ThemeMotion {
pub duration_ms: Option<u32>,
pub easing: Option<String>,
pub reduced_motion: Option<ThemeReducedMotion>,
pub animation: Option<ThemeAnimationMode>,
pub preset: Option<ThemeAnimationPreset>,
pub speed: Option<u16>,
}
impl ThemeMotion {
pub fn new() -> Self {
Self {
duration_ms: None,
easing: None,
reduced_motion: None,
animation: None,
preset: None,
speed: None,
}
}
pub fn dur(mut self, duration: Duration) -> Self {
self.duration_ms = Some(duration.as_millis().min(u128::from(u32::MAX)) as u32);
self
}
pub fn dur_ms(mut self, duration_ms: u32) -> Self {
self.duration_ms = Some(duration_ms);
self
}
pub fn ease(mut self, easing: impl Into<String>) -> Self {
self.easing = Some(easing.into());
self
}
pub fn reduced(mut self, reduced_motion: ThemeReducedMotion) -> Self {
self.reduced_motion = Some(reduced_motion);
self
}
pub fn anim(mut self, animation: ThemeAnimationMode) -> Self {
self.animation = Some(animation);
self
}
pub fn preset(mut self, preset: ThemeAnimationPreset) -> Self {
self.preset = Some(preset);
self
}
pub fn speed(mut self, speed: u16) -> Self {
self.speed = Some(normalize_animation_speed(speed));
self
}
}
impl Default for ThemeMotion {
fn default() -> Self {
Self::new()
}
}
pub fn theme() -> ThemeConfig {
ThemeConfig::new()
}
pub fn theme_def(id: impl AsRef<str>, label: impl Into<String>) -> ThemeDefinition {
ThemeDefinition::new(id, label)
}
pub fn themes() -> ThemeRegistry {
ThemeRegistry::new()
}
pub fn default_themes() -> ThemeRegistry {
ThemeRegistry::defaults()
}
pub fn motion() -> ThemeMotion {
ThemeMotion::new()
}
#[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 registry(self, registry: ThemeRegistry) -> Self {
self.with_registry(registry)
}
pub fn with_theme(mut self, theme: ThemeDefinition) -> Self {
self.registry.insert_theme(theme);
self
}
pub fn theme(self, theme: ThemeDefinition) -> Self {
self.with_theme(theme)
}
#[allow(clippy::should_implement_trait)]
pub fn add(self, theme: ThemeDefinition) -> Self {
self.with_theme(theme)
}
pub fn with_default_theme(mut self, theme: impl AsRef<str>) -> Self {
self.default_theme = theme_id(theme);
self
}
pub fn default_theme(self, theme: impl AsRef<str>) -> Self {
self.with_default_theme(theme)
}
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 system_theme(self, light_theme: impl AsRef<str>, dark_theme: impl AsRef<str>) -> Self {
self.with_system_theme(light_theme, dark_theme)
}
pub fn with_storage_key(mut self, storage_key: impl Into<String>) -> Self {
self.storage_key = storage_key.into();
self
}
pub fn storage(self, storage_key: impl Into<String>) -> Self {
self.with_storage_key(storage_key)
}
pub fn with_attribute(mut self, attribute: impl Into<String>) -> Self {
self.attribute = attribute.into();
self
}
pub fn attr(self, attribute: impl Into<String>) -> Self {
self.with_attribute(attribute)
}
pub fn with_target(mut self, target: impl Into<String>) -> Self {
self.target = target.into();
self
}
pub fn target(self, target: impl Into<String>) -> Self {
self.with_target(target)
}
pub fn with_duration_ms(mut self, duration_ms: u32) -> Self {
self.duration_ms = duration_ms;
self
}
pub fn dur(self, duration: Duration) -> Self {
self.with_duration_ms(duration.as_millis().min(u128::from(u32::MAX)) as u32)
}
pub fn dur_ms(self, duration_ms: u32) -> Self {
self.with_duration_ms(duration_ms)
}
pub fn with_easing(mut self, easing: impl Into<String>) -> Self {
self.easing = easing.into();
self
}
pub fn ease(self, easing: impl Into<String>) -> Self {
self.with_easing(easing)
}
pub fn with_reduced_motion(mut self, reduced_motion: ThemeReducedMotion) -> Self {
self.reduced_motion = reduced_motion;
self
}
pub fn reduced(self, reduced_motion: ThemeReducedMotion) -> Self {
self.with_reduced_motion(reduced_motion)
}
#[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 anim(self, animation: ThemeAnimationMode) -> Self {
self.with_animation(animation)
}
pub fn with_animation_preset(mut self, animation_preset: ThemeAnimationPreset) -> Self {
self.animation_preset = animation_preset;
self
}
pub fn preset(self, animation_preset: ThemeAnimationPreset) -> Self {
self.with_animation_preset(animation_preset)
}
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 anim_storage(self, animation_storage_key: impl Into<String>) -> Self {
self.with_animation_storage_key(animation_storage_key)
}
pub fn with_animation_speed(mut self, animation_speed: u16) -> Self {
self.animation_speed = normalize_animation_speed(animation_speed);
self
}
pub fn speed(self, animation_speed: u16) -> Self {
self.with_animation_speed(animation_speed)
}
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 speed_storage(self, animation_speed_storage_key: impl Into<String>) -> Self {
self.with_animation_speed_storage_key(animation_speed_storage_key)
}
pub fn with_view_transition_name_isolation(mut self, isolate: bool) -> Self {
self.isolate_view_transition_names = isolate;
self
}
pub fn isolate_names(self, isolate: bool) -> Self {
self.with_view_transition_name_isolation(isolate)
}
pub fn with_runtime_path(mut self, runtime_path: impl Into<String>) -> Self {
self.runtime_path = runtime_path.into();
self
}
pub fn runtime(self, runtime_path: impl Into<String>) -> Self {
self.with_runtime_path(runtime_path)
}
pub fn motion(mut self, motion: ThemeMotion) -> Self {
if let Some(duration_ms) = motion.duration_ms {
self.duration_ms = duration_ms;
}
if let Some(easing) = motion.easing {
self.easing = easing;
}
if let Some(reduced_motion) = motion.reduced_motion {
self.reduced_motion = reduced_motion;
}
if let Some(animation) = motion.animation {
self.animation = animation;
}
if let Some(preset) = motion.preset {
self.animation_preset = preset;
}
if let Some(speed) = motion.speed {
self.animation_speed = speed;
}
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 to_compact_json(&self) -> Result<String, serde_json::Error> {
let mut value = serde_json::to_value(self)?;
let default = serde_json::to_value(ThemeConfig::default())?;
if let (Some(object), Some(defaults)) = (value.as_object_mut(), default.as_object()) {
for key in [
"defaultTheme",
"systemLightTheme",
"systemDarkTheme",
"storageKey",
"attribute",
"target",
"durationMs",
"easing",
"reducedMotion",
"animation",
"animationPreset",
"animationStorageKey",
"animationSpeed",
"animationSpeedStorageKey",
"isolateViewTransitionNames",
"runtimePath",
] {
if object.get(key) == defaults.get(key) {
object.remove(key);
}
}
}
serde_json::to_string(&value)
}
pub fn to_preferred_json(
&self,
format: ThemeSerializationFormat,
) -> Result<String, serde_json::Error> {
match format {
ThemeSerializationFormat::StableJson | ThemeSerializationFormat::ReadableJson => {
self.to_json()
}
ThemeSerializationFormat::CompactJson => self.to_compact_json(),
}
}
pub fn with_route_profile(mut self, profile: ThemePresetProfile) -> Self {
profile.apply_to_config(&mut self);
self
}
pub fn route_profile(self, profile: ThemePresetProfile) -> Self {
self.with_route_profile(profile)
}
pub fn cache_key(&self, route: Option<&str>) -> String {
theme_cache_key(self, route, None)
}
pub fn manifest_fragment(&self, policy: &ThemeRoutePolicy) -> ThemeManifestFragment {
theme_manifest_fragment(self, policy)
}
pub fn output_report(&self, policy: &ThemeRoutePolicy) -> ThemeOutputReport {
theme_output_report(self, policy)
}
pub fn explain(&self, policy: &ThemeRoutePolicy) -> ThemeExplainReport {
explain_theme(self, policy)
}
pub fn try_validated(self) -> Result<Self, ThemeConfigError> {
let report = self.validate();
if report.is_valid() {
Ok(self)
} else {
Err(ThemeConfigError { report })
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ThemeConfigError {
pub report: ThemeValidationReport,
}
impl fmt::Display for ThemeConfigError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let count = self.report.errors().count();
write!(f, "invalid Theme config ({count} error(s))")
}
}
impl std::error::Error for ThemeConfigError {}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum ThemeRuntimeEmission {
Always,
#[default]
WhenUsed,
PrepaintOnly,
Disabled,
}
impl ThemeRuntimeEmission {
pub const fn as_attr(self) -> &'static str {
match self {
Self::Always => "always",
Self::WhenUsed => "when-used",
Self::PrepaintOnly => "prepaint-only",
Self::Disabled => "disabled",
}
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum ThemeSerializationFormat {
#[default]
StableJson,
ReadableJson,
CompactJson,
}
impl ThemeSerializationFormat {
pub const fn as_attr(self) -> &'static str {
match self {
Self::StableJson => "stable-json",
Self::ReadableJson => "readable-json",
Self::CompactJson => "compact-json",
}
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum ThemeDiagnosticVerbosity {
Off,
Summary,
#[default]
Detailed,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum ThemeFallbackStrategy {
#[default]
SystemTheme,
StaticTokens,
NativePort,
DisableRuntime,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum ThemePresetProfile {
Conservative,
#[default]
Balanced,
Expressive,
}
impl ThemePresetProfile {
pub const fn as_attr(self) -> &'static str {
match self {
Self::Conservative => "conservative",
Self::Balanced => "balanced",
Self::Expressive => "expressive",
}
}
pub fn apply_to_config(self, config: &mut ThemeConfig) {
match self {
Self::Conservative => {
config.duration_ms = config.duration_ms.min(120);
config.reduced_motion = ThemeReducedMotion::Respect;
config.animation = ThemeAnimationMode::CssOnly;
config.animation_preset = ThemeAnimationPreset::CrossFade;
config.animation_speed = normalize_animation_speed(75);
config.isolate_view_transition_names = true;
}
Self::Balanced => {
config.duration_ms = config.duration_ms.max(160).min(260);
config.reduced_motion = ThemeReducedMotion::Respect;
config.animation = ThemeAnimationMode::ViewTransition;
config.animation_speed = normalize_animation_speed(config.animation_speed);
}
Self::Expressive => {
config.duration_ms = config.duration_ms.max(260);
config.animation = ThemeAnimationMode::ViewTransition;
config.animation_preset = ThemeAnimationPreset::RadialWipe;
config.animation_speed = normalize_animation_speed(140);
config.isolate_view_transition_names = true;
}
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ThemeInteropPolicy {
pub strata: bool,
pub resume: bool,
pub native_port: bool,
pub viewtx: bool,
pub hoverfx: bool,
pub textfx: bool,
}
impl Default for ThemeInteropPolicy {
fn default() -> Self {
Self {
strata: true,
resume: true,
native_port: true,
viewtx: true,
hoverfx: true,
textfx: true,
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ThemeOutputBudget {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_config_bytes: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_runtime_bytes: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_style_bytes: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_theme_count: Option<usize>,
}
impl ThemeOutputBudget {
pub fn new() -> Self {
Self::default()
}
pub fn config_bytes(mut self, max: usize) -> Self {
self.max_config_bytes = Some(max);
self
}
pub fn runtime_bytes(mut self, max: usize) -> Self {
self.max_runtime_bytes = Some(max);
self
}
pub fn style_bytes(mut self, max: usize) -> Self {
self.max_style_bytes = Some(max);
self
}
pub fn theme_count(mut self, max: usize) -> Self {
self.max_theme_count = Some(max);
self
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ThemeRoutePolicy {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub route: Option<String>,
pub enabled: bool,
pub profile: ThemePresetProfile,
pub emission: ThemeRuntimeEmission,
pub serialization: ThemeSerializationFormat,
pub diagnostics: ThemeDiagnosticVerbosity,
pub fallback: ThemeFallbackStrategy,
#[serde(default)]
pub interop: ThemeInteropPolicy,
#[serde(default)]
pub budget: ThemeOutputBudget,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub labels: BTreeMap<String, String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tags: Vec<String>,
}
impl Default for ThemeRoutePolicy {
fn default() -> Self {
Self {
route: None,
enabled: true,
profile: ThemePresetProfile::Balanced,
emission: ThemeRuntimeEmission::WhenUsed,
serialization: ThemeSerializationFormat::StableJson,
diagnostics: ThemeDiagnosticVerbosity::Detailed,
fallback: ThemeFallbackStrategy::SystemTheme,
interop: ThemeInteropPolicy::default(),
budget: ThemeOutputBudget::default(),
labels: BTreeMap::new(),
tags: Vec::new(),
}
}
}
impl ThemeRoutePolicy {
pub fn new() -> Self {
Self::default()
}
pub fn route(mut self, route: impl Into<String>) -> Self {
self.route = Some(route.into());
self
}
pub fn enabled(mut self, enabled: bool) -> Self {
self.enabled = enabled;
self
}
pub fn profile(mut self, profile: ThemePresetProfile) -> Self {
self.profile = profile;
self
}
pub fn emission(mut self, emission: ThemeRuntimeEmission) -> Self {
self.emission = emission;
self
}
pub fn serialization(mut self, serialization: ThemeSerializationFormat) -> Self {
self.serialization = serialization;
self
}
pub fn diagnostics(mut self, diagnostics: ThemeDiagnosticVerbosity) -> Self {
self.diagnostics = diagnostics;
self
}
pub fn fallback(mut self, fallback: ThemeFallbackStrategy) -> Self {
self.fallback = fallback;
self
}
pub fn budget(mut self, budget: ThemeOutputBudget) -> Self {
self.budget = budget;
self
}
pub fn label(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
self.labels.insert(key.into(), value.into());
self
}
pub fn tag(mut self, tag: impl Into<String>) -> Self {
let tag = tag.into();
if !tag.is_empty() && !self.tags.contains(&tag) {
self.tags.push(tag);
self.tags.sort();
}
self
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ThemeManifestFragment {
pub package: String,
pub version: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub route: Option<String>,
pub enabled: bool,
pub cache_key: String,
pub default_theme: String,
pub runtime_path: String,
pub profile: ThemePresetProfile,
pub emission: ThemeRuntimeEmission,
pub fallback: ThemeFallbackStrategy,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub labels: BTreeMap<String, String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tags: Vec<String>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub metrics: BTreeMap<String, u64>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub policies: BTreeMap<String, serde_json::Value>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ThemeOutputViolation {
pub field: String,
pub actual: usize,
pub budget: usize,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ThemeOutputReport {
pub package: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub route: Option<String>,
pub cache_key: String,
pub config_bytes: usize,
pub runtime_bytes: usize,
pub style_bytes: usize,
pub theme_count: usize,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub violations: Vec<ThemeOutputViolation>,
}
impl ThemeOutputReport {
pub fn is_within_budget(&self) -> bool {
self.violations.is_empty()
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ThemeExplainReport {
pub package: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub route: Option<String>,
pub cache_key: String,
pub runtime_decision: String,
pub token_decision: String,
pub fallback_decision: String,
pub validation: ThemeValidationReport,
pub manifest: ThemeManifestFragment,
pub output: ThemeOutputReport,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub notes: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ThemeCompatibilityRow {
pub target: String,
pub support: String,
pub runtime: String,
pub fallback: String,
pub notes: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ThemeCompatibilityMatrix {
pub package: String,
pub rows: Vec<ThemeCompatibilityRow>,
}
pub trait ThemeManifestPolicyHook {
fn apply(&self, fragment: ThemeManifestFragment) -> Option<ThemeManifestFragment>;
}
pub fn apply_theme_manifest_hook<H>(
config: &ThemeConfig,
policy: &ThemeRoutePolicy,
hook: &H,
) -> Option<ThemeManifestFragment>
where
H: ThemeManifestPolicyHook,
{
hook.apply(theme_manifest_fragment(config, policy))
}
pub fn theme_route_policy() -> ThemeRoutePolicy {
ThemeRoutePolicy::new()
}
pub fn theme_output_budget() -> ThemeOutputBudget {
ThemeOutputBudget::new()
}
pub fn theme_cache_key(config: &ThemeConfig, route: Option<&str>, extra: Option<&str>) -> String {
let json = config.to_json().unwrap_or_default();
stable_hash_hex([
THEME_PACKAGE_NAME,
THEME_PACKAGE_VERSION,
route.unwrap_or("*"),
extra.unwrap_or(""),
json.as_str(),
])
}
pub fn theme_manifest_fragment(
config: &ThemeConfig,
policy: &ThemeRoutePolicy,
) -> ThemeManifestFragment {
let output = theme_output_report(config, policy);
let mut metrics = BTreeMap::new();
metrics.insert("configBytes".to_string(), output.config_bytes as u64);
metrics.insert("runtimeBytes".to_string(), output.runtime_bytes as u64);
metrics.insert("styleBytes".to_string(), output.style_bytes as u64);
metrics.insert("themeCount".to_string(), output.theme_count as u64);
let mut policies = BTreeMap::new();
policies.insert(
"interop".to_string(),
serde_json::to_value(&policy.interop).unwrap_or(serde_json::Value::Null),
);
policies.insert(
"route".to_string(),
serde_json::json!({
"enabled": policy.enabled,
"profile": policy.profile,
"emission": policy.emission,
"serialization": policy.serialization,
"fallback": policy.fallback,
}),
);
ThemeManifestFragment {
package: THEME_PACKAGE_NAME.to_string(),
version: THEME_PACKAGE_VERSION.to_string(),
route: policy.route.clone(),
enabled: policy.enabled,
cache_key: output.cache_key,
default_theme: config.default_theme.clone(),
runtime_path: config.runtime_path.clone(),
profile: policy.profile,
emission: policy.emission,
fallback: policy.fallback,
labels: policy.labels.clone(),
tags: policy.tags.clone(),
metrics,
policies,
}
}
pub fn theme_output_report(config: &ThemeConfig, policy: &ThemeRoutePolicy) -> ThemeOutputReport {
let config_json = config
.to_preferred_json(policy.serialization)
.unwrap_or_default();
let runtime_bytes = if policy.enabled
&& !matches!(
policy.emission,
ThemeRuntimeEmission::Disabled | ThemeRuntimeEmission::PrepaintOnly
) {
config.runtime_path.len()
} else {
0
};
let style_bytes = config
.registry
.themes
.iter()
.map(|theme| theme_tokens_css(theme).len())
.sum::<usize>();
let theme_count = config.registry.themes.len();
let mut violations = Vec::new();
push_theme_budget_violation(
&mut violations,
"configBytes",
config_json.len(),
policy.budget.max_config_bytes,
);
push_theme_budget_violation(
&mut violations,
"runtimeBytes",
runtime_bytes,
policy.budget.max_runtime_bytes,
);
push_theme_budget_violation(
&mut violations,
"styleBytes",
style_bytes,
policy.budget.max_style_bytes,
);
push_theme_budget_violation(
&mut violations,
"themeCount",
theme_count,
policy.budget.max_theme_count,
);
ThemeOutputReport {
package: THEME_PACKAGE_NAME.to_string(),
route: policy.route.clone(),
cache_key: theme_cache_key(
config,
policy.route.as_deref(),
Some(policy.profile.as_attr()),
),
config_bytes: config_json.len(),
runtime_bytes,
style_bytes,
theme_count,
violations,
}
}
pub fn explain_theme(config: &ThemeConfig, policy: &ThemeRoutePolicy) -> ThemeExplainReport {
let validation = config.validate();
let output = theme_output_report(config, policy);
let manifest = theme_manifest_fragment(config, policy);
let runtime_decision = if !policy.enabled {
"route disabled theme emission".to_string()
} else if policy.emission == ThemeRuntimeEmission::Disabled {
"theme runtime disabled by route policy".to_string()
} else if policy.emission == ThemeRuntimeEmission::PrepaintOnly {
"only prepaint CSS and data attributes should be emitted".to_string()
} else {
"theme runtime emitted with resumable handlers and storage policy".to_string()
};
let token_decision = format!(
"{} themes produce {} bytes of token CSS",
output.theme_count, output.style_bytes
);
let fallback_decision = format!("fallback strategy: {:?}", policy.fallback);
let mut notes = Vec::new();
if !validation.is_valid() {
notes.push("validation errors must be resolved before SSR emission".to_string());
}
if policy.interop.hoverfx {
notes.push("HoverFX can consume theme CSS custom properties".to_string());
}
if policy.interop.textfx {
notes.push("TextFX gradients can reference theme visual tokens".to_string());
}
if !output.is_within_budget() {
notes.push("one or more theme output budgets were exceeded".to_string());
}
ThemeExplainReport {
package: THEME_PACKAGE_NAME.to_string(),
route: policy.route.clone(),
cache_key: output.cache_key.clone(),
runtime_decision,
token_decision,
fallback_decision,
validation,
manifest,
output,
notes,
}
}
pub fn theme_compatibility_matrix() -> ThemeCompatibilityMatrix {
ThemeCompatibilityMatrix {
package: THEME_PACKAGE_NAME.to_string(),
rows: vec![
ThemeCompatibilityRow {
target: "web".to_string(),
support: "full".to_string(),
runtime: "prepaint CSS plus module runtime".to_string(),
fallback: "system-theme".to_string(),
notes: "ViewTX, HoverFX, and TextFX can consume shared theme policy".to_string(),
},
ThemeCompatibilityRow {
target: "server".to_string(),
support: "manifest".to_string(),
runtime: "route-gated config/style/runtime emission".to_string(),
fallback: "static-tokens".to_string(),
notes: "resume/Strata consumers can use manifest fragments and cache keys"
.to_string(),
},
ThemeCompatibilityRow {
target: "native".to_string(),
support: "adapter".to_string(),
runtime: "native-port theme actions".to_string(),
fallback: "native-port".to_string(),
notes: "native renderers can consume theme ids and visual token manifests"
.to_string(),
},
ThemeCompatibilityRow {
target: "cli".to_string(),
support: "report".to_string(),
runtime: "none".to_string(),
fallback: "stable-json".to_string(),
notes: "budget reports track config, style, runtime bytes, and theme counts"
.to_string(),
},
],
}
}
pub fn theme_native_port_hints(
config: &ThemeConfig,
policy: &ThemeRoutePolicy,
) -> BTreeMap<String, String> {
let mut hints = BTreeMap::new();
hints.insert("package".to_string(), THEME_PACKAGE_NAME.to_string());
hints.insert("version".to_string(), THEME_PACKAGE_VERSION.to_string());
hints.insert(
"cacheKey".to_string(),
theme_cache_key(config, policy.route.as_deref(), None),
);
hints.insert(
"route".to_string(),
policy.route.clone().unwrap_or_else(|| "*".to_string()),
);
hints.insert("runtime".to_string(), policy.emission.as_attr().to_string());
hints.insert("profile".to_string(), policy.profile.as_attr().to_string());
hints.insert("defaultTheme".to_string(), config.default_theme.clone());
hints.insert(
"themeCount".to_string(),
config.registry.themes.len().to_string(),
);
hints
}
fn push_theme_budget_violation(
violations: &mut Vec<ThemeOutputViolation>,
field: &str,
actual: usize,
budget: Option<usize>,
) {
if let Some(budget) = budget
&& actual > budget
{
violations.push(ThemeOutputViolation {
field: field.to_string(),
actual,
budget,
});
}
}
fn stable_hash_hex<'a>(parts: impl IntoIterator<Item = &'a str>) -> String {
let mut hash = 0xcbf29ce484222325u64;
for part in parts {
for byte in part.as_bytes() {
hash ^= u64::from(*byte);
hash = hash.wrapping_mul(0x100000001b3);
}
hash ^= 0xff;
hash = hash.wrapping_mul(0x100000001b3);
}
format!("{hash:016x}")
}
impl std::ops::Add<ThemeDefinition> for ThemeConfig {
type Output = Self;
fn add(self, rhs: ThemeDefinition) -> Self::Output {
self.with_theme(rhs)
}
}
pub mod prelude {
pub use crate::integration::*;
pub use crate::{
ThemeAnim, ThemeAnimationMode, ThemeCfg, ThemeColorScheme, ThemeCompatibilityMatrix,
ThemeCompatibilityRow, ThemeConfig, ThemeDef, ThemeDefinition, ThemeDiagnosticVerbosity,
ThemeExplainReport, ThemeFallbackStrategy, ThemeInteropPolicy, ThemeManifestFragment,
ThemeManifestPolicyHook, ThemeOutputBudget, ThemeOutputReport, ThemeOutputViolation,
ThemePreset, ThemePresetProfile, ThemeReducedMotion, ThemeReg, ThemeRegistry,
ThemeRoutePolicy, ThemeRuntimeEmission, ThemeSerializationFormat, ThemeVisualTokenRole,
apply_theme_manifest_hook, default_themes, explain_theme, theme, theme_cache_key,
theme_compatibility_matrix, theme_def, theme_id, theme_manifest_fragment,
theme_native_port_hints, theme_output_budget, theme_output_report, theme_route_policy,
themes,
};
}
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_helpers_write_canonical_theme_tokens() {
let theme = ThemeDefinition::new("brand", "Brand")
.with_visual_token(ThemeVisualTokenRole::Background, "#101010")
.with_visual_tokens([
(ThemeVisualTokenRole::Text, "#f8fafc"),
(ThemeVisualTokenRole::Accent, "#22d3ee"),
]);
assert_eq!(
theme.tokens.get(THEME_TOKEN_BG).map(String::as_str),
Some("#101010")
);
assert_eq!(
theme.tokens.get(THEME_TOKEN_FG).map(String::as_str),
Some("#f8fafc")
);
assert_eq!(
theme.tokens.get(THEME_TOKEN_ACCENT).map(String::as_str),
Some("#22d3ee")
);
assert!(theme_tokens_css(&theme).contains("--dxt-accent:#22d3ee;"));
assert_eq!(
theme_visual_token_css_var("surface-border"),
Some(THEME_TOKEN_SURFACE_BORDER)
);
assert_eq!(
theme_visual_token_css_var("primary"),
Some(THEME_TOKEN_ACCENT)
);
assert_eq!(theme_visual_token_css_var("unknown"), None);
}
#[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");
let cached = theme_visual_token_manifest_json().expect("manifest serializes again");
assert_eq!(json, cached);
assert!(json.contains("\"changeEvent\":\"dioxus-theme:change\""));
assert!(json.contains("\"key\":\"surfaceBorder\""));
assert!(json.contains("\"cssVar\":\"--dxt-accent\""));
}
#[test]
fn compact_config_omits_default_scalar_values() {
let default = ThemeConfig::default();
let full = default.to_json().expect("full config serializes");
let compact = default
.to_compact_json()
.expect("compact config serializes");
assert!(compact.len() < full.len());
assert!(compact.contains("\"registry\""));
assert!(!compact.contains("\"storageKey\""));
assert!(!compact.contains("\"animationPreset\""));
let custom = default
.with_storage_key("brand-theme")
.with_duration_ms(140);
let custom_compact = custom
.to_compact_json()
.expect("custom compact config serializes");
assert!(custom_compact.contains("\"storageKey\":\"brand-theme\""));
assert!(custom_compact.contains("\"durationMs\":140"));
}
#[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)
);
}
#[test]
fn short_theme_builders_match_long_form_config() {
let custom = theme_def("brand", "Brand")
.scheme(ThemeColorScheme::Dark)
.token(THEME_TOKEN_BG, "#111111");
let config = theme()
.add(custom)
.default_theme("brand")
.dur_ms(140)
.ease("linear")
.reduced(ThemeReducedMotion::Ignore)
.preset(ThemeAnimationPreset::RadialWipe)
.speed(180);
assert_eq!(config.default_theme, "brand");
assert_eq!(config.duration_ms, 140);
assert_eq!(config.easing, "linear");
assert_eq!(config.reduced_motion, ThemeReducedMotion::Ignore);
assert_eq!(config.animation_preset, ThemeAnimationPreset::RadialWipe);
assert_eq!(config.animation_speed, 180);
assert!(config.registry.ids().contains(&"brand"));
}
#[test]
fn route_policy_manifest_and_budget_report_track_theme_output() {
let config = theme()
.route_profile(ThemePresetProfile::Expressive)
.theme(
theme_def("brand", "Brand")
.scheme(ThemeColorScheme::Dark)
.token(THEME_TOKEN_ACCENT, "#22d3ee"),
)
.default_theme("brand");
let policy = theme_route_policy()
.route("/theme")
.profile(ThemePresetProfile::Expressive)
.emission(ThemeRuntimeEmission::PrepaintOnly)
.serialization(ThemeSerializationFormat::CompactJson)
.budget(theme_output_budget().config_bytes(4).theme_count(8))
.label("owner", "design-system")
.tag("tokens");
let manifest = config.manifest_fragment(&policy);
let report = config.output_report(&policy);
let hints = theme_native_port_hints(&config, &policy);
assert_eq!(manifest.package, THEME_PACKAGE_NAME);
assert_eq!(manifest.route.as_deref(), Some("/theme"));
assert_eq!(manifest.profile, ThemePresetProfile::Expressive);
assert_eq!(manifest.emission, ThemeRuntimeEmission::PrepaintOnly);
assert_eq!(manifest.metrics["themeCount"], report.theme_count as u64);
assert_eq!(report.runtime_bytes, 0);
assert!(
report
.violations
.iter()
.any(|violation| violation.field == "configBytes")
);
assert_eq!(hints["defaultTheme"], "brand");
assert_eq!(
config.cache_key(Some("/theme")),
config.cache_key(Some("/theme"))
);
}
#[test]
fn explain_report_matrix_and_hook_cover_visual_interop() {
struct DropDisabled;
impl ThemeManifestPolicyHook for DropDisabled {
fn apply(&self, fragment: ThemeManifestFragment) -> Option<ThemeManifestFragment> {
fragment.enabled.then_some(fragment)
}
}
let config = ThemeConfig::default();
let enabled_policy = theme_route_policy().route("/theme").tag("hoverfx");
let disabled_policy = theme_route_policy()
.route("/theme/off")
.enabled(false)
.emission(ThemeRuntimeEmission::Disabled);
let explain = explain_theme(&config, &enabled_policy);
let matrix = theme_compatibility_matrix();
assert!(explain.validation.is_valid());
assert!(explain.notes.iter().any(|note| note.contains("HoverFX")));
assert!(matrix.rows.iter().any(|row| row.target == "native"));
assert!(apply_theme_manifest_hook(&config, &enabled_policy, &DropDisabled).is_some());
assert!(apply_theme_manifest_hook(&config, &disabled_policy, &DropDisabled).is_none());
}
}