pub use dioprism_theme::core::{
DEFAULT_THEME_ANIMATION_SPEED, DEFAULT_THEME_ANIMATION_SPEED_STORAGE_KEY,
DEFAULT_THEME_ANIMATION_STORAGE_KEY, DEFAULT_THEME_ATTRIBUTE, DEFAULT_THEME_DURATION_MS,
DEFAULT_THEME_EASING, DEFAULT_THEME_RUNTIME_BASE_PATH, DEFAULT_THEME_RUNTIME_PATH,
DEFAULT_THEME_RUNTIME_VERSION, DEFAULT_THEME_STORAGE_KEY, DEFAULT_THEME_TARGET,
MAX_THEME_ANIMATION_SPEED, MIN_THEME_ANIMATION_SPEED, THEME_CHANGE_EVENT, THEME_TOKEN_ACCENT,
THEME_TOKEN_BACKGROUND, THEME_TOKEN_BG, THEME_TOKEN_FG, THEME_TOKEN_MUTED, THEME_TOKEN_PANEL,
THEME_TOKEN_PANEL_BORDER, THEME_TOKEN_SURFACE, THEME_TOKEN_SURFACE_BORDER, THEME_TOKEN_TEXT,
THEME_VISUAL_TOKEN_MANIFEST_VERSION, THEME_VISUAL_TOKENS, ThemeAnimationMode,
ThemeAnimationPreset, ThemeColorScheme, ThemeConfig, ThemeDefinition, ThemeReducedMotion,
ThemeRegistry, ThemeValidationCode, ThemeValidationIssue, ThemeValidationReport,
ThemeValidationSeverity, ThemeVisualTokenDefinition, ThemeVisualTokenManifest,
ThemeVisualTokenRole, is_safe_css_token_value, is_valid_theme_attribute, is_valid_theme_target,
normalize_animation_speed, theme_id, theme_tokens_css, theme_visual_token_css_var,
theme_visual_token_manifest, theme_visual_token_manifest_json,
};
use std::{
collections::hash_map::DefaultHasher,
hash::{Hash, Hasher},
sync::{Mutex, OnceLock},
};
use dioprism_assets::js::{JsMinifyOptions, JsSourceKind, content_hash_version, minify_js};
#[derive(Clone, Debug)]
struct ThemePrepaintCssCacheEntry {
key: ThemeConfigCacheKey,
css: String,
}
static THEME_PREPAINT_CSS_CACHE: OnceLock<Mutex<Option<ThemePrepaintCssCacheEntry>>> =
OnceLock::new();
#[derive(Clone, Debug)]
struct ThemeRuntimeRequirementsCacheEntry {
key: ThemeConfigCacheKey,
requirements: Vec<dioprism_resume::RouteRuntimeRequirement>,
}
static THEME_RUNTIME_REQUIREMENTS_CACHE: OnceLock<
Mutex<Option<ThemeRuntimeRequirementsCacheEntry>>,
> = OnceLock::new();
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
struct ThemeConfigCacheKey {
hash: u64,
len: usize,
}
pub const DEFAULT_THEME_PREPAINT_STYLE_ID: &str = "__DXT_THEME_PREPAINT__";
pub const DEFAULT_THEME_CONFIG_ID: &str = "__DXT_THEME_CONFIG__";
pub const THEME_RUNTIME_ASSET_ID: &str = "theme.runtime";
pub const THEME_ROUTE_CONFIG_ID: &str = "theme.config";
pub const THEME_TOGGLE_HANDLER: &str = "theme.toggle";
pub const THEME_SET_HANDLER: &str = "theme.set";
pub const THEME_CYCLE_HANDLER: &str = "theme.cycle";
pub const THEME_ANIMATION_HANDLER: &str = "theme.animation";
pub const THEME_ANIMATION_SPEED_HANDLER: &str = "theme.animation-speed";
pub fn theme_runtime_asset() -> dioprism_resume::RuntimeAssetDescriptor {
let version = content_hash_version(dioprism_theme_runtime_js());
dioprism_resume::RuntimeAsset::new(
format!("{DEFAULT_THEME_RUNTIME_BASE_PATH}?v={version}"),
version,
dioprism_resume::RuntimeAssetKind::ModuleScript,
)
}
fn theme_runtime_asset_for_config(config: &ThemeConfig) -> dioprism_resume::RuntimeAssetDescriptor {
let mut asset = theme_runtime_asset();
if !config.runtime_path.trim().is_empty() && config.runtime_path != DEFAULT_THEME_RUNTIME_PATH {
asset.path = config.runtime_path.clone();
}
asset
}
#[derive(Clone, Copy, Debug)]
pub struct ThemeRuntimeRequirements<'a> {
config: &'a ThemeConfig,
}
impl<'a> ThemeRuntimeRequirements<'a> {
pub fn new(config: &'a ThemeConfig) -> Self {
Self { config }
}
}
pub fn theme_requirement_provider(config: &ThemeConfig) -> ThemeRuntimeRequirements<'_> {
ThemeRuntimeRequirements::new(config)
}
impl dioprism_resume::RuntimeRequirementProvider for ThemeRuntimeRequirements<'_> {
fn runtime_requirements(
&self,
) -> dioprism_resume::Result<Vec<dioprism_resume::RouteRuntimeRequirement>> {
Ok(theme_runtime_requirements(self.config))
}
}
pub fn theme_runtime_requirements(
config: &ThemeConfig,
) -> Vec<dioprism_resume::RouteRuntimeRequirement> {
let key = theme_config_cache_key(config);
let cache = THEME_RUNTIME_REQUIREMENTS_CACHE.get_or_init(|| Mutex::new(None));
if let Ok(guard) = cache.lock()
&& let Some(entry) = guard.as_ref().filter(|entry| entry.key == key)
{
return entry.requirements.clone();
}
let requirements = theme_runtime_requirements_uncached(config);
if let Ok(mut guard) = cache.lock() {
*guard = Some(ThemeRuntimeRequirementsCacheEntry {
key,
requirements: requirements.clone(),
});
}
requirements
}
fn theme_runtime_requirements_uncached(
config: &ThemeConfig,
) -> Vec<dioprism_resume::RouteRuntimeRequirement> {
let runtime = theme_runtime_asset_for_config(config)
.with_preload_policy(dioprism_resume::RuntimeAssetPreload::Idle)
.with_fetch_priority(dioprism_resume::RuntimeAssetFetchPriority::Low);
let requirement = dioprism_resume::RouteRuntimeRequirement::new("theme")
.with_priority(dioprism_resume::RouteRuntimePriority::UserVisible)
.with_preload_policy(dioprism_resume::RuntimeAssetPreload::Idle)
.with_cleanup_hook("theme.apply")
.with_render_lane("theme")
.with_asset(THEME_RUNTIME_ASSET_ID, runtime)
.with_config_ref(THEME_ROUTE_CONFIG_ID, DEFAULT_THEME_CONFIG_ID)
.with_policy_value(
"theme.switch",
serde_json::json!({
"storageKey": config.storage_key,
"attribute": config.attribute,
"animation": config.animation,
"animationPreset": config.animation_preset,
"animationStorageKey": config.animation_storage_key,
"animationSpeed": config.animation_speed,
"animationSpeedStorageKey": config.animation_speed_storage_key,
"reducedMotion": config.reduced_motion,
"isolateViewTransitionNames": config.isolate_view_transition_names,
"configRef": DEFAULT_THEME_CONFIG_ID,
"changeEvent": THEME_CHANGE_EVENT,
"visualTokenManifest": theme_visual_token_manifest(),
"packageCost": "low",
}),
);
vec![requirement]
}
pub fn register_theme_handlers(
manifest: &mut dioprism_resume::ResumeManifest,
config: &ThemeConfig,
) {
let runtime = theme_runtime_asset_for_config(config)
.with_preload_policy(dioprism_resume::RuntimeAssetPreload::Idle)
.with_fetch_priority(dioprism_resume::RuntimeAssetFetchPriority::Low);
for (id, symbol) in [
(THEME_TOGGLE_HANDLER, "toggleTheme"),
(THEME_SET_HANDLER, "setTheme"),
(THEME_CYCLE_HANDLER, "cycleTheme"),
(THEME_ANIMATION_HANDLER, "setAnimationPreset"),
(THEME_ANIMATION_SPEED_HANDLER, "setAnimationSpeed"),
] {
manifest.insert(
id,
dioprism_resume::HandlerAsset::from_runtime_asset(runtime.clone(), symbol)
.with_preload_policy(dioprism_resume::RuntimeAssetPreload::Idle)
.with_fetch_priority(dioprism_resume::RuntimeAssetFetchPriority::Low),
);
}
}
pub fn theme_prepaint_style_tag(config: &ThemeConfig) -> String {
theme_prepaint_style_tag_with_nonce(config, None)
}
pub fn theme_prepaint_style_tag_with_nonce(config: &ThemeConfig, nonce: Option<&str>) -> String {
let nonce = nonce
.map(|nonce| format!(r#" nonce="{}""#, dioprism_resume::escape_attr(nonce)))
.unwrap_or_default();
format!(
r#"<style id="{id}"{nonce}>{css}</style>"#,
id = DEFAULT_THEME_PREPAINT_STYLE_ID,
nonce = nonce,
css = theme_prepaint_css_minified(config)
)
}
pub fn theme_head_tags(config: &ThemeConfig, nonce: Option<&str>) -> String {
let style = theme_prepaint_style_tag_with_nonce(config, nonce);
let config_script = theme_config_script(config, nonce);
let bootstrap = theme_bootstrap_script(config, nonce);
let mut tags = String::with_capacity(style.len() + config_script.len() + bootstrap.len());
tags.push_str(&style);
tags.push_str(&config_script);
tags.push_str(&bootstrap);
tags
}
pub fn theme_head_tags_for_route(
config: &ThemeConfig,
policy: &dioprism_theme::core::ThemeRoutePolicy,
nonce: Option<&str>,
) -> String {
if !policy.enabled || policy.emission == dioprism_theme::core::ThemeRuntimeEmission::Disabled {
return String::new();
}
if policy.emission == dioprism_theme::core::ThemeRuntimeEmission::PrepaintOnly {
return theme_prepaint_style_tag_with_nonce(config, nonce);
}
theme_head_tags(config, nonce)
}
pub fn theme_runtime_requirements_for_route(
config: &ThemeConfig,
policy: &dioprism_theme::core::ThemeRoutePolicy,
) -> Vec<dioprism_resume::RouteRuntimeRequirement> {
if !policy.enabled
|| matches!(
policy.emission,
dioprism_theme::core::ThemeRuntimeEmission::Disabled
| dioprism_theme::core::ThemeRuntimeEmission::PrepaintOnly
)
{
return Vec::new();
}
theme_runtime_requirements(config)
}
pub fn theme_should_register_handlers(policy: &dioprism_theme::core::ThemeRoutePolicy) -> bool {
policy.enabled
&& !matches!(
policy.emission,
dioprism_theme::core::ThemeRuntimeEmission::Disabled
| dioprism_theme::core::ThemeRuntimeEmission::PrepaintOnly
)
}
pub fn theme_ssr_output_report(
config: &ThemeConfig,
policy: &dioprism_theme::core::ThemeRoutePolicy,
) -> dioprism_theme::core::ThemeOutputReport {
config.output_report(policy)
}
pub fn theme_doctor(
config: &ThemeConfig,
policy: &dioprism_theme::core::ThemeRoutePolicy,
) -> dioprism_theme::core::ThemeExplainReport {
config.explain(policy)
}
pub fn theme_head(config: &ThemeConfig) -> String {
theme_head_tags(config, None)
}
pub fn theme_head_nonce(config: &ThemeConfig, nonce: &str) -> String {
theme_head_tags(config, Some(nonce))
}
pub fn theme_config_script(config: &ThemeConfig, nonce: Option<&str>) -> String {
let nonce = nonce
.map(|nonce| format!(r#" nonce="{}""#, dioprism_resume::escape_attr(nonce)))
.unwrap_or_default();
let json = config
.to_compact_json()
.or_else(|_| config.to_json())
.unwrap_or_else(|_| "{}".to_string());
format!(
r#"<script type="application/json" id="{id}"{nonce}>{json}</script>"#,
id = DEFAULT_THEME_CONFIG_ID,
nonce = nonce,
json = dioprism_resume::escape_script_json(&json)
)
}
pub fn theme_bootstrap_script(config: &ThemeConfig, nonce: Option<&str>) -> String {
let nonce = nonce
.map(|nonce| format!(r#" nonce="{}""#, dioprism_resume::escape_attr(nonce)))
.unwrap_or_default();
format!(
concat!(
r#"<script{nonce}>(()=>{{"#,
"const k={storage_key};const a={attribute};const d={default_theme};",
"const sl={system_light};const sd={system_dark};",
"let p=\"\";try{{p=localStorage.getItem(k)||\"\"}}catch(_){{}}",
"if(!p)p=d;",
"const eff=p===\"system\"?((typeof matchMedia===\"function\"&&matchMedia(\"(prefers-color-scheme: dark)\").matches)?sd:sl):p;",
"const r=document.documentElement;r.setAttribute(a,eff);r.setAttribute(\"data-dxt-theme-preference\",p);",
r#"}})();</script>"#
),
nonce = nonce,
storage_key = js_string_literal(&config.storage_key),
attribute = js_string_literal(&config.attribute),
default_theme = js_string_literal(&config.default_theme),
system_light = js_string_literal(&config.system_light_theme),
system_dark = js_string_literal(&config.system_dark_theme),
)
}
pub fn theme_prepaint_css(config: &ThemeConfig) -> String {
let mut css = String::with_capacity(1024 + config.registry.themes.len() * 192);
let target = sanitize_target_selector(&config.target);
let attr = sanitize_attr_name(&config.attribute);
for theme in &config.registry.themes {
if theme.is_system() {
continue;
}
css.push_str(&target);
css.push('[');
css.push_str(&attr);
css.push_str("=\"");
css.push_str(&css_string(&theme.id));
css.push_str("\"]{");
css.push_str(&theme_tokens_css(theme));
css.push('}');
}
css.push_str(&target);
css.push_str("{background:var(--dxt-bg);color:var(--dxt-fg);}");
css.push_str("body{background:var(--dxt-bg);color:var(--dxt-fg);}");
if config.isolate_view_transition_names {
css.push_str(
r#"html[data-dxt-theme-viewtx-scope="root"] :where(body, body *){view-transition-name:none!important;}"#,
);
}
if config.animation.is_animated() {
let duration_ms = effective_duration_ms(config.duration_ms, config.animation_speed);
let duration_ms = duration_ms.to_string();
let easing = css_easing(&config.easing);
push_theme_transition_rule(&mut css, &target, &duration_ms, easing);
}
css.push_str("@media (prefers-reduced-motion: reduce){html[data-dxt-theme-transition=\"css\"],html[data-dxt-theme-transition=\"css\"] *,html[data-dxt-theme-transition=\"css\"] body{transition:none!important;}}");
css
}
pub fn theme_prepaint_css_minified(config: &ThemeConfig) -> String {
let key = theme_config_cache_key(config);
let cache = THEME_PREPAINT_CSS_CACHE.get_or_init(|| Mutex::new(None));
if let Ok(guard) = cache.lock()
&& let Some(entry) = guard.as_ref().filter(|entry| entry.key == key)
{
return entry.css.clone();
}
let css = dioprism_assets::css::minify_css(
&theme_prepaint_css(config),
dioprism_assets::css::CssMinifyOptions::default()
.with_filename("dioprism-theme-prepaint.css"),
)
.expect("dioprism-theme prepaint CSS must be valid");
if let Ok(mut guard) = cache.lock() {
*guard = Some(ThemePrepaintCssCacheEntry {
key,
css: css.clone(),
});
}
css
}
fn theme_config_cache_key(config: &ThemeConfig) -> ThemeConfigCacheKey {
let serialized = config.to_json().unwrap_or_else(|_| {
format!(
"{}:{}:{}",
config.target, config.attribute, config.runtime_path
)
});
let mut hasher = DefaultHasher::new();
serialized.hash(&mut hasher);
ThemeConfigCacheKey {
hash: hasher.finish(),
len: serialized.len(),
}
}
fn push_theme_transition_rule(css: &mut String, target: &str, duration_ms: &str, easing: &str) {
css.push_str(target);
css.push_str("[data-dxt-theme-transition=\"css\"],");
css.push_str(target);
css.push_str("[data-dxt-theme-transition=\"css\"] body,");
css.push_str(target);
css.push_str("[data-dxt-theme-transition=\"css\"] *{transition:");
for (index, property) in ["background-color", "color", "border-color", "box-shadow"]
.into_iter()
.enumerate()
{
if index > 0 {
css.push(',');
}
css.push_str(property);
css.push_str(" var(--dxt-theme-transition-duration,");
css.push_str(duration_ms);
css.push_str("ms) ");
css.push_str(easing);
}
css.push_str(";}");
}
pub fn dioprism_theme_runtime_js() -> &'static str {
static RUNTIME_JS: OnceLock<String> = OnceLock::new();
RUNTIME_JS
.get_or_init(|| {
minify_js(
DIOXUS_THEME_RUNTIME_JS,
JsMinifyOptions::new()
.with_filename("dioprism-theme.js")
.with_source_kind(JsSourceKind::Script),
)
.expect("Theme runtime JavaScript must remain valid")
})
.as_str()
}
pub fn theme_js() -> &'static str {
dioprism_theme_runtime_js()
}
pub mod prelude {
pub use crate::ssr::{
ThemeConfig, ThemeRuntimeRequirements, dioprism_theme_runtime_js, register_theme_handlers,
theme_doctor, theme_head, theme_head_nonce, theme_head_tags, theme_head_tags_for_route,
theme_js, theme_requirement_provider, theme_runtime_asset, theme_runtime_requirements,
theme_runtime_requirements_for_route, theme_should_register_handlers,
theme_ssr_output_report,
};
pub use dioprism_theme::core::prelude::*;
}
fn sanitize_target_selector(target: &str) -> String {
let trimmed = target.trim();
if matches!(trimmed, "html" | ":root") {
"html".to_string()
} else if trimmed
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.' | '#'))
{
trimmed.to_string()
} else {
"html".to_string()
}
}
fn sanitize_attr_name(attribute: &str) -> String {
let trimmed = attribute.trim();
if !trimmed.is_empty()
&& trimmed
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | ':'))
{
trimmed.to_string()
} else {
DEFAULT_THEME_ATTRIBUTE.to_string()
}
}
fn css_string(value: &str) -> String {
value
.chars()
.filter(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_'))
.collect()
}
fn css_easing(value: &str) -> &str {
let trimmed = value.trim();
if trimmed.is_empty()
|| trimmed.chars().any(|ch| {
!(ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.' | ',' | '(' | ')' | ' '))
})
{
DEFAULT_THEME_EASING
} else {
trimmed
}
}
fn js_string_literal(value: &str) -> String {
serde_json::to_string(value).unwrap_or_else(|_| "\"\"".to_string())
}
fn effective_duration_ms(duration_ms: u32, speed: u16) -> u32 {
let speed = u32::from(normalize_animation_speed(speed)).max(1);
((duration_ms.max(1) * 100) / speed).max(40)
}
pub const DIOXUS_THEME_RUNTIME_JS: &str = r#"
const CONFIG_ID = "__DXT_THEME_CONFIG__";
const DEFAULT_CONFIG = {
registry: { themes: [] },
defaultTheme: "system",
systemLightTheme: "light",
systemDarkTheme: "dark",
storageKey: "dioprism-theme",
attribute: "data-dxt-theme",
target: "html",
durationMs: 220,
easing: "ease-in-out",
reducedMotion: "respect",
animation: "view-transition",
animationPreset: "cross-fade",
animationStorageKey: "dioprism-theme-animation",
animationSpeed: 100,
animationSpeedStorageKey: "dioprism-theme-animation-speed",
isolateViewTransitionNames: true,
runtimePath: "/assets/dioprism-theme.js?v=1"
};
const THEME_CHANGE_EVENT = "dioprism-theme:change";
const VISUAL_TOKEN_MANIFEST_VERSION = 1;
const VISUAL_TOKEN_CSS_VARS = {
background: "--dxt-bg",
text: "--dxt-fg",
muted: "--dxt-muted",
surface: "--dxt-panel",
surfaceBorder: "--dxt-panel-border",
accent: "--dxt-accent"
};
let CONFIG_CACHE = null;
let CONFIG_CACHE_KEY = "";
const STORAGE_STATE = new Map();
let SPEED_STORAGE_FRAME = 0;
let SPEED_STORAGE_PENDING = null;
let STORAGE_SYNC_INSTALLED = false;
let VIEW_TRANSITION_SCOPE_TOKEN = 0;
function normalizeTargetSelector(value) {
const target = String(value || DEFAULT_CONFIG.target).trim();
if (!target || target === ":root") return DEFAULT_CONFIG.target;
return target;
}
function normalizeAttribute(value) {
const attr = String(value || DEFAULT_CONFIG.attribute).trim();
return /^[A-Za-z0-9_:-]+$/.test(attr) ? attr : DEFAULT_CONFIG.attribute;
}
function normalizeConfig(value) {
const config = { ...DEFAULT_CONFIG, ...(value && typeof value === "object" ? value : {}) };
if (!config.registry || !Array.isArray(config.registry.themes)) {
config.registry = { themes: [] };
}
config.target = normalizeTargetSelector(config.target);
config.attribute = normalizeAttribute(config.attribute);
config._targetSelector = config.target;
config._attribute = config.attribute;
config._themes = config.registry.themes.filter((theme) => theme && typeof theme.id === "string");
config._themeById = new Map();
config._cycleIds = [];
config._nonSystemThemes = [];
for (const theme of config._themes) {
config._themeById.set(theme.id, theme);
config._cycleIds.push(theme.id);
if (theme.id !== "system") config._nonSystemThemes.push(theme);
}
return config;
}
function readRouteConfig() {
try {
const dxr = globalThis.__DXR__;
const runtimeConfig = dxr && dxr.api && dxr.api.runtimeConfig;
if (typeof runtimeConfig === "function") {
const config = runtimeConfig("theme.config");
if (config && typeof config === "object") return config;
}
} catch (_) {}
return null;
}
function readConfig() {
const routeConfig = readRouteConfig();
if (routeConfig) {
let key = "";
try { key = `route:${JSON.stringify(routeConfig)}`; } catch (_) { key = `route:${Date.now()}`; }
if (CONFIG_CACHE && CONFIG_CACHE_KEY === key) return CONFIG_CACHE;
CONFIG_CACHE = normalizeConfig(routeConfig);
CONFIG_CACHE_KEY = key;
return CONFIG_CACHE;
}
const node = document.getElementById(CONFIG_ID);
const text = node && node.textContent ? node.textContent : "";
const key = text ? `script:${text}` : "default";
if (CONFIG_CACHE && CONFIG_CACHE_KEY === key) return CONFIG_CACHE;
try {
CONFIG_CACHE = normalizeConfig(text ? JSON.parse(text) : DEFAULT_CONFIG);
} catch (_) {
CONFIG_CACHE = normalizeConfig(DEFAULT_CONFIG);
}
CONFIG_CACHE_KEY = key;
return CONFIG_CACHE;
}
function rootFor(config) {
const target = config._targetSelector || config.target || DEFAULT_CONFIG.target;
if (!target || target === "html" || target === ":root") return document.documentElement;
try {
return document.querySelector(target) || document.documentElement;
} catch (_) {
return document.documentElement;
}
}
function themes(config) {
return Array.isArray(config._themes) ? config._themes : ((config.registry && Array.isArray(config.registry.themes)) ? config.registry.themes : []);
}
function themeById(config, id) {
const normalized = String(id || "").trim().toLowerCase();
if (config._themeById && typeof config._themeById.get === "function") return config._themeById.get(normalized);
return themes(config).find((theme) => theme && theme.id === normalized);
}
function cachedStorageGet(key) {
if (STORAGE_STATE.has(key)) return STORAGE_STATE.get(key);
let value = "";
try {
value = localStorage.getItem(key) || "";
} catch (_) {}
STORAGE_STATE.set(key, value);
return value;
}
function cachedStorageSet(key, value) {
const normalized = String(value || "");
STORAGE_STATE.set(key, normalized);
try {
localStorage.setItem(key, normalized);
} catch (_) {}
}
function storageGet(config) {
return cachedStorageGet(config.storageKey || DEFAULT_CONFIG.storageKey);
}
function storageSet(config, value) {
cachedStorageSet(config.storageKey || DEFAULT_CONFIG.storageKey, value);
}
const ANIMATION_PRESETS = ["fade", "cross-fade", "slide", "radial-wipe", "masked-wave"];
const ANIMATION_LABELS = {
"fade": "Fade",
"cross-fade": "Cross fade",
"slide": "Slide",
"radial-wipe": "Radial wipe",
"masked-wave": "Masked wave"
};
const MIN_ANIMATION_SPEED = 25;
const MAX_ANIMATION_SPEED = 300;
function normalizeAnimationPreset(value, fallback = DEFAULT_CONFIG.animationPreset) {
const normalized = String(value || "").trim().toLowerCase();
if (ANIMATION_PRESETS.includes(normalized)) return normalized;
const fallbackValue = String(fallback || "").trim().toLowerCase();
return ANIMATION_PRESETS.includes(fallbackValue) ? fallbackValue : DEFAULT_CONFIG.animationPreset;
}
function animationStorageKey(config) {
return config.animationStorageKey || DEFAULT_CONFIG.animationStorageKey;
}
function animationStorageGet(config) {
return cachedStorageGet(animationStorageKey(config));
}
function animationStorageSet(config, value) {
cachedStorageSet(animationStorageKey(config), value);
}
function animationLabel(preset) {
return ANIMATION_LABELS[preset] || preset;
}
function currentAnimationPreset(config) {
return normalizeAnimationPreset(animationStorageGet(config) || config.animationPreset, config.animationPreset);
}
function normalizeAnimationSpeed(value, fallback = DEFAULT_CONFIG.animationSpeed) {
const number = Number.parseInt(String(value || ""), 10);
const fallbackNumber = Number.parseInt(String(fallback || DEFAULT_CONFIG.animationSpeed), 10);
const candidate = Number.isFinite(number) ? number : fallbackNumber;
return Math.min(MAX_ANIMATION_SPEED, Math.max(MIN_ANIMATION_SPEED, Number.isFinite(candidate) ? candidate : DEFAULT_CONFIG.animationSpeed));
}
function animationSpeedStorageKey(config) {
return config.animationSpeedStorageKey || DEFAULT_CONFIG.animationSpeedStorageKey;
}
function animationSpeedStorageGet(config) {
return cachedStorageGet(animationSpeedStorageKey(config));
}
function animationSpeedStorageSet(config, value) {
cachedStorageSet(animationSpeedStorageKey(config), String(value));
}
function scheduleAnimationSpeedStorageSet(config, value) {
SPEED_STORAGE_PENDING = { key: animationSpeedStorageKey(config), value };
if (SPEED_STORAGE_FRAME) return;
const flush = () => {
const pending = SPEED_STORAGE_PENDING;
SPEED_STORAGE_PENDING = null;
SPEED_STORAGE_FRAME = 0;
if (!pending) return;
cachedStorageSet(pending.key, pending.value);
};
SPEED_STORAGE_FRAME = typeof requestAnimationFrame === "function"
? requestAnimationFrame(flush)
: setTimeout(flush, 16);
}
function currentAnimationSpeed(config) {
return normalizeAnimationSpeed(animationSpeedStorageGet(config) || config.animationSpeed, config.animationSpeed);
}
function durationForSpeed(config, speed) {
const base = Math.max(1, Number(config.durationMs || DEFAULT_CONFIG.durationMs));
return Math.max(40, Math.round(base * 100 / normalizeAnimationSpeed(speed, config.animationSpeed)));
}
function effectiveDuration(config) {
return durationForSpeed(config, currentAnimationSpeed(config));
}
function prefersDark() {
return typeof matchMedia === "function" && matchMedia("(prefers-color-scheme: dark)").matches;
}
function reduceMotion(config) {
return config.reducedMotion === "respect"
&& typeof matchMedia === "function"
&& matchMedia("(prefers-reduced-motion: reduce)").matches;
}
function installViewTransitionScope(root, config) {
if (!root || config.isolateViewTransitionNames === false) return "";
const token = String(++VIEW_TRANSITION_SCOPE_TOKEN);
root.setAttribute("data-dxt-theme-transition", "view");
root.setAttribute("data-dxt-theme-viewtx-scope", "root");
root.setAttribute("data-dxt-theme-viewtx-token", token);
return token;
}
function clearViewTransitionScope(root, token) {
if (!root || !token) return;
if (root.getAttribute("data-dxt-theme-viewtx-token") !== String(token)) return;
root.removeAttribute("data-dxt-theme-transition");
root.removeAttribute("data-dxt-theme-viewtx-scope");
root.removeAttribute("data-dxt-theme-viewtx-token");
}
function effectiveThemeId(config, preference) {
const selected = themeById(config, preference) ? preference : (config.defaultTheme || "system");
if (selected === "system") return prefersDark() ? config.systemDarkTheme : config.systemLightTheme;
return selected;
}
function safeTokenMap(theme) {
const tokens = theme && theme.tokens && typeof theme.tokens === "object" ? theme.tokens : {};
const output = {};
for (const [name, value] of Object.entries(tokens)) {
if (typeof name === "string" && name.startsWith("--")) output[name] = String(value);
}
return output;
}
function visualTokenValues(tokens) {
const values = {};
for (const [key, cssVar] of Object.entries(VISUAL_TOKEN_CSS_VARS)) {
values[key] = Object.prototype.hasOwnProperty.call(tokens, cssVar) ? tokens[cssVar] : "";
}
return values;
}
function visualTokenEntries(tokens) {
const entries = {};
for (const [key, cssVar] of Object.entries(VISUAL_TOKEN_CSS_VARS)) {
entries[key] = {
key,
cssVar,
value: Object.prototype.hasOwnProperty.call(tokens, cssVar) ? tokens[cssVar] : ""
};
}
return entries;
}
function buildThemeChangeDetail(config, selected, effective, theme, selectedTheme, animationPreset, animationSpeed) {
const tokens = safeTokenMap(theme);
return {
theme: effective,
preference: selected,
label: (selectedTheme || theme || {}).label || effective,
colorScheme: (theme && theme.colorScheme) || "",
animationPreset,
animationSpeed,
tokens,
visualTokens: visualTokenValues(tokens),
visualTokenEntries: visualTokenEntries(tokens),
visualTokenCssVars: { ...VISUAL_TOKEN_CSS_VARS },
visualTokenManifestVersion: VISUAL_TOKEN_MANIFEST_VERSION,
changeEvent: THEME_CHANGE_EVENT,
storageKey: config.storageKey || DEFAULT_CONFIG.storageKey
};
}
function applyInlineTokens(root, theme) {
if (!theme) return;
if (theme.colorScheme) {
root.style.colorScheme = theme.colorScheme === "system" ? "light dark" : theme.colorScheme;
}
const tokens = theme.tokens || {};
for (const [name, value] of Object.entries(tokens)) {
if (name.startsWith("--")) root.style.setProperty(name, String(value));
}
}
function patchControls(preference, effective, label) {
document.querySelectorAll("[data-dxt-theme-current]").forEach((node) => {
node.textContent = label || preference || effective;
});
document.querySelectorAll("[data-dxt-theme-toggle-label]").forEach((node) => {
node.textContent = label || preference || effective;
});
document.querySelectorAll("[data-dxt-theme-select]").forEach((node) => {
if ("value" in node) node.value = preference;
});
}
function patchAnimationControls(preset) {
const label = animationLabel(preset);
document.querySelectorAll("[data-dxt-theme-animation-current]").forEach((node) => {
node.textContent = label;
});
document.querySelectorAll("[data-dxt-theme-animation-select]").forEach((node) => {
if ("value" in node) node.value = preset;
});
}
function patchAnimationSpeedControls(speed) {
document.querySelectorAll("[data-dxt-theme-animation-speed-current]").forEach((node) => {
node.textContent = `${speed}%`;
});
document.querySelectorAll("[data-dxt-theme-animation-speed]").forEach((node) => {
if ("value" in node) node.value = String(speed);
});
}
function dispatchThemeChange(root, detail) {
root.dispatchEvent(new CustomEvent(THEME_CHANGE_EVENT, { bubbles: true, detail }));
}
function dispatchAnimationChange(root, detail) {
root.dispatchEvent(new CustomEvent("dioprism-theme:animation-change", { bubbles: true, detail }));
}
function dispatchAnimationSpeedChange(root, detail) {
root.dispatchEvent(new CustomEvent("dioprism-theme:animation-speed-change", { bubbles: true, detail }));
}
function eventOrigin(event, element) {
const viewport = viewportSize();
if (event && Number.isFinite(event.clientX) && Number.isFinite(event.clientY)) {
return { x: Math.min(viewport.width, Math.max(0, event.clientX)), y: Math.min(viewport.height, Math.max(0, event.clientY)) };
}
if (element && typeof element.getBoundingClientRect === "function") {
const rect = element.getBoundingClientRect();
return { x: Math.min(viewport.width, Math.max(0, rect.left + rect.width / 2)), y: Math.min(viewport.height, Math.max(0, rect.top + rect.height / 2)) };
}
return { x: viewport.width / 2, y: viewport.height / 2 };
}
function viewportSize() {
const visual = globalThis.visualViewport;
const doc = document.documentElement;
return {
width: Math.max(1, Math.ceil(Math.max(Number(innerWidth) || 0, Number(doc && doc.clientWidth) || 0, Number(visual && visual.width) || 0))),
height: Math.max(1, Math.ceil(Math.max(Number(innerHeight) || 0, Number(doc && doc.clientHeight) || 0, Number(visual && visual.height) || 0)))
};
}
function fullViewportRadius(origin) {
const viewport = viewportSize();
const distances = [
Math.hypot(origin.x, origin.y),
Math.hypot(viewport.width - origin.x, origin.y),
Math.hypot(origin.x, viewport.height - origin.y),
Math.hypot(viewport.width - origin.x, viewport.height - origin.y)
];
const farthestCorner = Math.max(...distances);
const vmaxFloor = Math.max(viewport.width, viewport.height) * 1.5;
return Math.ceil(Math.max(farthestCorner + 24, vmaxFloor));
}
function animatePseudo(pseudoElement, keyframes, options) {
try {
if (typeof document.documentElement.animate !== "function") return null;
return document.documentElement.animate(keyframes, { ...options, pseudoElement });
} catch (_) {
return null;
}
}
function maskedWaveClipFrames(origin) {
const fromLeft = origin.x <= viewportSize().width / 2;
return [
{ clipPath: smoothWavePolygon(0, fromLeft) },
{ clipPath: smoothWavePolygon(0.58, fromLeft), offset: 0.58 },
{ clipPath: smoothWavePolygon(1, fromLeft) }
];
}
function smoothWavePolygon(progress, fromLeft) {
const samples = 18;
const amplitude = 7;
const cycles = 1.85;
const overshoot = 18;
const travel = 100 + overshoot * 2;
const leadingEdge = fromLeft
? progress * travel - overshoot
: 100 - (progress * travel - overshoot);
const points = [fromLeft ? "0% 0%" : "100% 0%"];
for (let index = 0; index <= samples; index += 1) {
const ratio = index / samples;
const y = ratio * 100;
const taper = Math.sin(Math.PI * ratio);
const wave = Math.sin(ratio * Math.PI * 2 * cycles + Math.PI / 7) * amplitude * taper;
const x = leadingEdge + wave;
points.push(`${x.toFixed(2)}% ${y.toFixed(2)}%`);
}
points.push(fromLeft ? "0% 100%" : "100% 100%");
return `polygon(${points.join(",")})`;
}
function animateThemePreset(preset, config, options = {}) {
const duration = effectiveDuration(config);
const easing = config.easing || DEFAULT_CONFIG.easing;
const timing = { duration, easing, fill: "both" };
const origin = eventOrigin(options.event, options.element);
switch (preset) {
case "fade":
animatePseudo("::view-transition-new(root)", { opacity: [0, 1] }, timing);
break;
case "slide":
animatePseudo("::view-transition-old(root)", { opacity: [1, 0], transform: ["translateY(0)", "translateY(-10px)"] }, timing);
animatePseudo("::view-transition-new(root)", { opacity: [0, 1], transform: ["translateY(10px)", "translateY(0)"] }, timing);
break;
case "radial-wipe": {
const radius = fullViewportRadius(origin);
const at = `${Math.round(origin.x)}px ${Math.round(origin.y)}px`;
animatePseudo("::view-transition-old(root)", { opacity: [1, 0.92] }, timing);
animatePseudo("::view-transition-new(root)", { clipPath: [`circle(0px at ${at})`, `circle(${radius}px at ${at})`] }, timing);
break;
}
case "masked-wave":
animatePseudo("::view-transition-old(root)", { opacity: [1, 0.96] }, timing);
animatePseudo("::view-transition-new(root)", maskedWaveClipFrames(origin), timing);
break;
case "cross-fade":
default:
animatePseudo("::view-transition-old(root)", { opacity: [1, 0] }, timing);
animatePseudo("::view-transition-new(root)", { opacity: [0, 1] }, timing);
break;
}
}
function runThemeTransition(transition, preset, config, options = {}) {
if (!transition || !transition.ready) return;
transition.ready
.then(() => animateThemePreset(preset, config, options))
.catch(() => {});
}
function applyAnimationPreset(value, options = {}) {
const config = readConfig();
const root = rootFor(config);
const preset = normalizeAnimationPreset(value || animationStorageGet(config) || config.animationPreset, config.animationPreset);
const detail = { preset, label: animationLabel(preset) };
root.setAttribute("data-dxt-theme-animation", preset);
patchAnimationControls(preset);
if (options.persist !== false) animationStorageSet(config, preset);
dispatchAnimationChange(root, detail);
return detail;
}
function applyAnimationSpeed(value, options = {}) {
const config = readConfig();
const root = rootFor(config);
const speed = normalizeAnimationSpeed(value || animationSpeedStorageGet(config) || config.animationSpeed, config.animationSpeed);
const detail = { speed, label: `${speed}%` };
root.setAttribute("data-dxt-theme-animation-speed", String(speed));
root.style.setProperty("--dxt-theme-transition-duration", `${durationForSpeed(config, speed)}ms`);
patchAnimationSpeedControls(speed);
if (options.persist !== false) scheduleAnimationSpeedStorageSet(config, speed);
dispatchAnimationSpeedChange(root, detail);
return detail;
}
function applyTheme(preference, options = {}) {
const config = readConfig();
const root = rootFor(config);
const selected = String(preference || storageGet(config) || config.defaultTheme || "system").trim().toLowerCase();
const effective = effectiveThemeId(config, selected);
const theme = themeById(config, effective);
const selectedTheme = themeById(config, selected);
const animationPreset = currentAnimationPreset(config);
const animationSpeed = currentAnimationSpeed(config);
const detail = buildThemeChangeDetail(config, selected, effective, theme, selectedTheme, animationPreset, animationSpeed);
const attr = config._attribute || config.attribute || DEFAULT_CONFIG.attribute;
const commit = () => {
root.setAttribute(attr, effective);
root.setAttribute("data-dxt-theme-preference", selected);
root.setAttribute("data-dxt-theme-animation", animationPreset);
root.setAttribute("data-dxt-theme-animation-speed", String(animationSpeed));
root.style.setProperty("--dxt-theme-transition-duration", `${effectiveDuration(config)}ms`);
applyInlineTokens(root, theme);
patchControls(selected, effective, detail.label);
patchAnimationControls(animationPreset);
patchAnimationSpeedControls(animationSpeed);
dispatchThemeChange(root, detail);
};
if (options.persist !== false) storageSet(config, selected);
if (options.animate === false || config.animation === "none" || reduceMotion(config)) {
commit();
return detail;
}
if (config.animation === "view-transition" && typeof document.startViewTransition === "function") {
const scopeToken = installViewTransitionScope(root, config);
let transition;
try {
transition = document.startViewTransition(commit);
} catch (_) {
clearViewTransitionScope(root, scopeToken);
root.setAttribute("data-dxt-theme-transition", "css");
commit();
setTimeout(() => root.removeAttribute("data-dxt-theme-transition"), effectiveDuration(config) + 40);
return detail;
}
runThemeTransition(transition, animationPreset, config, options);
if (transition && transition.finished) {
transition.finished.then(
() => clearViewTransitionScope(root, scopeToken),
() => clearViewTransitionScope(root, scopeToken)
);
} else {
setTimeout(() => clearViewTransitionScope(root, scopeToken), effectiveDuration(config) + 80);
}
return detail;
}
root.setAttribute("data-dxt-theme-transition", "css");
commit();
setTimeout(() => root.removeAttribute("data-dxt-theme-transition"), effectiveDuration(config) + 40);
return detail;
}
function nextToggleTheme(config, current) {
const list = Array.isArray(config._nonSystemThemes)
? config._nonSystemThemes
: themes(config).filter((theme) => theme && theme.id !== "system");
const fallback = config.defaultTheme === "system" ? config.systemDarkTheme : config.defaultTheme;
if (!list.length) return fallback || "dark";
const currentId = current === "system" ? effectiveThemeId(config, current) : current;
const fallbackIndex = Math.max(0, list.findIndex((theme) => theme.id === fallback));
const currentIndex = list.findIndex((theme) => theme.id === currentId);
if (currentIndex === fallbackIndex && list.length > 1) return list[(fallbackIndex + 1) % list.length].id;
return list[fallbackIndex] ? list[fallbackIndex].id : list[0].id;
}
function nextCycleTheme(config, current) {
const list = Array.isArray(config._cycleIds) ? config._cycleIds : themes(config).map((theme) => theme && theme.id).filter(Boolean);
if (!list.length) return config.defaultTheme || "system";
const index = list.indexOf(current);
return list[(index + 1 + list.length) % list.length];
}
function currentPreference(config) {
const root = rootFor(config);
return root.getAttribute("data-dxt-theme-preference") || storageGet(config) || config.defaultTheme || "system";
}
function patchResult(detail) {
return {
patches: [
{ kind: "set-text", selector: "[data-dxt-theme-current]", text: detail.label || detail.preference || detail.theme },
{ kind: "set-attribute", selector: "html", name: "data-dxt-theme-last-change", value: new Date().toISOString() }
]
};
}
function installStorageSync() {
if (STORAGE_SYNC_INSTALLED || typeof addEventListener !== "function") return;
STORAGE_SYNC_INSTALLED = true;
addEventListener("storage", (event) => {
const config = readConfig();
const key = event && event.key ? event.key : "";
if (key) STORAGE_STATE.set(key, event.newValue || "");
if (key === (config.storageKey || DEFAULT_CONFIG.storageKey)) {
applyTheme(event.newValue || config.defaultTheme || "system", { persist: false, animate: true });
} else if (key === animationStorageKey(config)) {
applyAnimationPreset(event.newValue || config.animationPreset, { persist: false });
} else if (key === animationSpeedStorageKey(config)) {
applyAnimationSpeed(event.newValue || config.animationSpeed, { persist: false });
}
});
}
export function installTheme() {
const config = readConfig();
installStorageSync();
applyAnimationPreset(animationStorageGet(config) || config.animationPreset, { persist: false });
applyAnimationSpeed(animationSpeedStorageGet(config) || config.animationSpeed, { persist: false });
applyTheme(storageGet(config) || config.defaultTheme || "system", { persist: false, animate: false });
if (typeof matchMedia === "function") {
const query = matchMedia("(prefers-color-scheme: dark)");
const refresh = () => {
if (currentPreference(readConfig()) === "system") applyTheme("system", { persist: false, animate: true });
};
if (typeof query.addEventListener === "function") query.addEventListener("change", refresh);
else if (typeof query.addListener === "function") query.addListener(refresh);
}
}
export async function setTheme({ element, event } = {}) {
const value = element && (element.value || element.getAttribute("data-dxt-theme") || element.getAttribute("data-theme"));
const detail = applyTheme(value || readConfig().defaultTheme || "system", { persist: true, animate: true, event, element });
return patchResult(detail);
}
export async function toggleTheme({ element, event } = {}) {
const config = readConfig();
const explicit = element && element.getAttribute("data-dxt-theme-next");
const next = explicit && themeById(config, explicit) ? explicit : nextToggleTheme(config, currentPreference(config));
const detail = applyTheme(next, { persist: true, animate: true, event, element });
return patchResult(detail);
}
export async function cycleTheme() {
const config = readConfig();
const detail = applyTheme(nextCycleTheme(config, currentPreference(config)), { persist: true, animate: true });
return patchResult(detail);
}
function patchAnimationResult(detail) {
return {
patches: [
{ kind: "set-text", selector: "[data-dxt-theme-animation-current]", text: detail.label || detail.preset },
{ kind: "set-attribute", selector: "html", name: "data-dxt-theme-animation", value: detail.preset }
]
};
}
export async function setAnimationPreset({ element } = {}) {
const value = element && (element.value || element.getAttribute("data-dxt-theme-animation"));
const detail = applyAnimationPreset(value || readConfig().animationPreset, { persist: true });
return patchAnimationResult(detail);
}
function patchAnimationSpeedResult(detail) {
return {
patches: [
{ kind: "set-text", selector: "[data-dxt-theme-animation-speed-current]", text: detail.label || `${detail.speed}%` },
{ kind: "set-attribute", selector: "html", name: "data-dxt-theme-animation-speed", value: String(detail.speed) },
{ kind: "set-style", selector: "html", name: "--dxt-theme-transition-duration", value: `${durationForSpeed(readConfig(), detail.speed)}ms` }
]
};
}
export async function setAnimationSpeed({ element } = {}) {
const value = element && (element.value || element.getAttribute("data-dxt-theme-animation-speed"));
const detail = applyAnimationSpeed(value || readConfig().animationSpeed, { persist: true });
return patchAnimationSpeedResult(detail);
}
"#;
#[cfg(test)]
mod tests {
use super::*;
fn test_config() -> ThemeConfig {
ThemeConfig::default().with_storage_key("test-theme")
}
#[test]
fn prepaint_style_contains_theme_tokens() {
let css = theme_prepaint_css(&test_config());
assert!(css.contains("html[data-dxt-theme=\"dark\"]"));
assert!(css.contains("--dxt-bg:#020617;"));
assert!(css.contains("data-dxt-theme-transition"));
assert!(css.contains("data-dxt-theme-viewtx-scope=\"root\""));
assert!(css.contains("view-transition-name:none!important"));
}
#[test]
fn prepaint_style_accepts_nonce() {
let tag = theme_prepaint_style_tag_with_nonce(&test_config(), Some("nonce-value"));
assert!(tag.contains("id=\"__DXT_THEME_PREPAINT__\""));
assert!(tag.contains("nonce=\"nonce-value\""));
}
#[test]
fn prepaint_minified_css_is_deterministic_and_budgeted() {
let config = test_config();
let first = theme_prepaint_css_minified(&config);
let second = theme_prepaint_css_minified(&config);
assert_eq!(first, second);
assert!(
first.len() < 4096,
"theme prepaint CSS grew to {} bytes",
first.len()
);
assert!(first.contains("transition:background-color"));
assert_eq!(
theme_config_cache_key(&config),
theme_config_cache_key(&config)
);
let linear = config.clone().with_easing("linear");
assert_ne!(
theme_config_cache_key(&config),
theme_config_cache_key(&linear)
);
assert!(theme_prepaint_css_minified(&linear).contains("linear"));
}
#[test]
fn config_script_escapes_breakouts() {
let config = test_config().with_storage_key("</script><script>alert(1)</script>");
let script = theme_config_script(&config, Some("nonce-value"));
assert!(script.contains("nonce=\"nonce-value\""));
assert!(!script.contains("</script><script>alert"));
assert!(script.contains("\"storageKey\":\""));
assert!(!script.contains("\"animationPreset\":\"cross-fade\""));
assert!(!script.contains("\"animationStorageKey\":\"dioprism-theme-animation\""));
assert!(!script.contains("\"animationSpeed\":100"));
assert!(
!script.contains("\"animationSpeedStorageKey\":\"dioprism-theme-animation-speed\"")
);
assert!(!script.contains("\"isolateViewTransitionNames\":true"));
}
#[test]
fn bootstrap_reads_storage_without_importing_runtime() {
let script = theme_bootstrap_script(&test_config(), None);
assert!(script.contains("localStorage.getItem"));
assert!(script.contains("setAttribute(a,eff)"));
assert!(!script.contains("import("));
}
#[test]
fn head_tags_emit_style_config_and_bootstrap() {
let tags = theme_head_tags(&test_config(), Some("nonce-value"));
assert!(tags.contains(DEFAULT_THEME_PREPAINT_STYLE_ID));
assert!(tags.contains(DEFAULT_THEME_CONFIG_ID));
assert!(tags.contains("localStorage.getItem"));
assert_eq!(tags.matches("nonce=\"nonce-value\"").count(), 3);
}
#[test]
fn runtime_exports_resumable_handlers() {
let js = DIOXUS_THEME_RUNTIME_JS;
assert!(js.contains("export async function toggleTheme"));
assert!(js.contains("export async function setTheme"));
assert!(js.contains("export async function cycleTheme"));
assert!(js.contains("export async function setAnimationPreset"));
assert!(js.contains("export async function setAnimationSpeed"));
assert!(js.contains("masked-wave"));
assert!(js.contains("function smoothWavePolygon"));
assert!(js.contains("ease-in-out"));
assert!(js.contains("function fullViewportRadius"));
assert!(js.contains("farthestCorner + 24"));
assert!(js.contains("function readRouteConfig"));
assert!(js.contains("function normalizeTargetSelector"));
assert!(js.contains("function normalizeAttribute"));
assert!(js.contains("config._themeById = new Map();"));
assert!(js.contains("config._cycleIds = [];"));
assert!(js.contains("const STORAGE_STATE = new Map();"));
assert!(js.contains("function cachedStorageGet"));
assert!(js.contains("scheduleAnimationSpeedStorageSet"));
assert!(js.contains("addEventListener(\"storage\""));
assert!(js.contains("function installViewTransitionScope"));
assert!(js.contains("data-dxt-theme-viewtx-token"));
assert!(js.contains("transition.finished.then"));
assert!(js.contains("const THEME_CHANGE_EVENT = \"dioprism-theme:change\""));
assert!(js.contains("function buildThemeChangeDetail"));
assert!(js.contains("visualTokens: visualTokenValues(tokens)"));
assert!(js.contains("visualTokenEntries: visualTokenEntries(tokens)"));
assert!(js.contains("visualTokenCssVars: { ...VISUAL_TOKEN_CSS_VARS }"));
assert!(js.contains("colorScheme: (theme && theme.colorScheme) || \"\""));
}
#[test]
fn runtime_js_is_minified_and_budgeted() {
let minified = dioprism_theme_runtime_js();
assert!(minified.len() < DIOXUS_THEME_RUNTIME_JS.len());
assert!(
minified.len() < 24_000,
"theme runtime grew to {} bytes",
minified.len()
);
}
#[test]
fn requirements_include_low_cost_runtime_asset() {
let requirements = theme_runtime_requirements(&test_config());
let repeated = theme_runtime_requirements(&test_config());
assert_eq!(requirements, repeated);
assert_eq!(requirements.len(), 1);
let requirement = &requirements[0];
assert_eq!(requirement.package, "theme");
assert!(requirement.assets.contains_key(THEME_RUNTIME_ASSET_ID));
assert!(requirement.configs.is_empty());
assert_eq!(
requirement.config_refs.get(THEME_ROUTE_CONFIG_ID),
Some(&DEFAULT_THEME_CONFIG_ID.to_string())
);
assert!(requirement.policies.contains_key("theme.switch"));
assert_eq!(
requirement.policies["theme.switch"]["isolateViewTransitionNames"],
serde_json::json!(true)
);
assert_eq!(
requirement.policies["theme.switch"]["changeEvent"],
serde_json::json!(THEME_CHANGE_EVENT)
);
assert_eq!(
requirement.policies["theme.switch"]["visualTokenManifest"]["version"],
serde_json::json!(THEME_VISUAL_TOKEN_MANIFEST_VERSION)
);
assert_eq!(
requirement.policies["theme.switch"]["visualTokenManifest"]["tokens"][5]["cssVar"],
serde_json::json!(THEME_TOKEN_ACCENT)
);
}
#[test]
fn register_theme_handlers_maps_public_ids_to_runtime_symbols() {
let mut manifest = dioprism_resume::ResumeManifest::default();
register_theme_handlers(&mut manifest, &test_config());
for (id, symbol) in [
(THEME_TOGGLE_HANDLER, "toggleTheme"),
(THEME_SET_HANDLER, "setTheme"),
(THEME_CYCLE_HANDLER, "cycleTheme"),
(THEME_ANIMATION_HANDLER, "setAnimationPreset"),
(THEME_ANIMATION_SPEED_HANDLER, "setAnimationSpeed"),
] {
let handler = manifest.get(id).expect("handler registered");
assert_eq!(handler.symbol, symbol);
assert!(handler.module.contains(DEFAULT_THEME_RUNTIME_BASE_PATH));
}
}
}
#[cfg(test)]
mod route_policy_tests {
use super::*;
#[test]
fn route_policy_gates_head_runtime_and_handlers() {
let config = ThemeConfig::default();
let disabled = dioprism_theme::core::theme_route_policy()
.route("/theme/off")
.enabled(false)
.emission(dioprism_theme::core::ThemeRuntimeEmission::Disabled);
let prepaint = dioprism_theme::core::theme_route_policy()
.route("/theme/prepaint")
.emission(dioprism_theme::core::ThemeRuntimeEmission::PrepaintOnly);
let enabled = dioprism_theme::core::theme_route_policy().route("/theme");
assert!(theme_head_tags_for_route(&config, &disabled, None).is_empty());
assert!(theme_runtime_requirements_for_route(&config, &disabled).is_empty());
assert!(!theme_should_register_handlers(&disabled));
assert!(theme_runtime_requirements_for_route(&config, &prepaint).is_empty());
assert!(theme_head_tags_for_route(&config, &prepaint, None).contains("<style"));
assert!(!theme_head_tags_for_route(&config, &prepaint, None).contains("<script"));
assert!(!theme_runtime_requirements_for_route(&config, &enabled).is_empty());
assert!(theme_should_register_handlers(&enabled));
}
#[test]
fn doctor_and_output_report_use_core_route_policy() {
let config = ThemeConfig::default();
let policy = dioprism_theme::core::theme_route_policy()
.route("/theme")
.budget(dioprism_theme::core::theme_output_budget().config_bytes(8));
let report = theme_ssr_output_report(&config, &policy);
let doctor = theme_doctor(&config, &policy);
assert_eq!(report.route.as_deref(), Some("/theme"));
assert_eq!(doctor.output.cache_key, report.cache_key);
assert!(doctor.validation.is_valid());
}
}
#[cfg(feature = "dioprism")]
#[derive(Clone, Copy, Debug)]
pub struct ThemeDioprismAdapter<'a> {
config: &'a ThemeConfig,
}
#[cfg(feature = "dioprism")]
impl<'a> ThemeDioprismAdapter<'a> {
pub const fn new(config: &'a ThemeConfig) -> Self {
Self { config }
}
}
#[cfg(feature = "dioprism")]
impl dioprism_core::DioprismAdapter for ThemeDioprismAdapter<'_> {
fn name(&self) -> &'static str {
"dioprism-theme"
}
fn runtime_requirements(
&self,
_ctx: &dioprism_core::DioprismRouteContext,
) -> Vec<dioprism_core::DioprismRuntimeRequirement> {
vec![
dioprism_core::DioprismRuntimeRequirement::new(self.name())
.with_asset(THEME_RUNTIME_ASSET_ID)
.with_capability("theme.prepaint")
.with_policy_value(
"theme.dioprism",
serde_json::json!({
"config": DEFAULT_THEME_CONFIG_ID,
"routeConfig": THEME_ROUTE_CONFIG_ID,
"runtimeAsset": THEME_RUNTIME_ASSET_ID,
"changeEvent": THEME_CHANGE_EVENT,
"dataFirst": true
}),
),
]
}
fn assets(
&self,
_ctx: &dioprism_core::DioprismRouteContext,
) -> Vec<dioprism_core::DioprismRuntimeAsset> {
let asset = theme_runtime_asset_for_config(self.config);
vec![
dioprism_core::DioprismRuntimeAsset::new(
THEME_RUNTIME_ASSET_ID,
self.name(),
dioprism_core::AssetKind::ModuleScript,
)
.url(asset.path)
.content_hash(asset.version)
.priority(dioprism_core::AssetPriority::Low)
.placement(dioprism_core::AssetPlacement::Lazy),
dioprism_core::DioprismRuntimeAsset::new(
"theme.prepaint",
self.name(),
dioprism_core::AssetKind::Style,
)
.inline_content(theme_prepaint_css_minified(self.config))
.nonce_required(true)
.priority(dioprism_core::AssetPriority::Critical)
.placement(dioprism_core::AssetPlacement::HeadPrepaint),
]
}
fn capabilities(
&self,
_ctx: &dioprism_core::DioprismRouteContext,
) -> Vec<dioprism_core::DioprismCapability> {
vec![
dioprism_core::DioprismCapability::new(
"theme.prepaint",
dioprism_core::CapabilityKind::ThemeSwitch,
)
.trigger(dioprism_core::CapabilityTrigger::Immediate)
.with_asset("theme.prepaint")
.with_asset(THEME_RUNTIME_ASSET_ID)
.fallback(dioprism_core::CapabilityFallback::NativeBrowser),
]
}
fn budget_entries(
&self,
_ctx: &dioprism_core::DioprismRouteContext,
) -> Vec<dioprism_core::DioprismBudgetEntry> {
vec![
dioprism_core::DioprismBudgetEntry::new(
"theme.prepaint-css",
self.name(),
dioprism_core::DioprismBudgetPolicy::bytes(
dioprism_core::DioprismBudgetCategory::InitialCss,
8 * 1024,
)
.warn_only(true),
)
.measured_value(theme_prepaint_css_minified(self.config).len()),
]
}
fn ssr_fragments(
&self,
ctx: &dioprism_core::DioprismSsrContext,
) -> Vec<dioprism_core::DioprismSsrFragment> {
vec![
dioprism_core::DioprismSsrFragment::new(
DEFAULT_THEME_PREPAINT_STYLE_ID,
dioprism_core::DioprismSsrFragmentKind::Head,
theme_prepaint_style_tag_with_nonce(self.config, ctx.nonce.as_deref()),
),
dioprism_core::DioprismSsrFragment::new(
DEFAULT_THEME_CONFIG_ID,
dioprism_core::DioprismSsrFragmentKind::Head,
theme_config_script(self.config, ctx.nonce.as_deref()),
),
]
}
}