use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
pub const DEFAULT_HOVERFX_RUNTIME_BASE_PATH: &str = "/assets/dioxus-hoverfx.js";
pub const DEFAULT_HOVERFX_WORKER_BASE_PATH: &str = "/assets/dioxus-hoverfx-worker.js";
pub const DEFAULT_HOVERFX_RUNTIME_VERSION: &str = "1";
pub const DEFAULT_HOVERFX_WORKER_VERSION: &str = "1";
pub const DEFAULT_HOVERFX_RUNTIME_PATH: &str = "/assets/dioxus-hoverfx.js?v=1";
pub const DEFAULT_HOVERFX_WORKER_PATH: &str = "/assets/dioxus-hoverfx-worker.js?v=1";
pub const DEFAULT_HOVERFX_RADIUS_PX: u16 = 180;
pub const DEFAULT_HOVERFX_STRENGTH: f32 = 1.0;
pub const DEFAULT_HOVERFX_SMOOTHING: f32 = 0.18;
pub const DEFAULT_HOVERFX_MAX_ACTIVE_ELEMENTS: u16 = 8;
pub const DEFAULT_HOVERFX_PERF_LAZY_LOCAL_LAYERS: bool = true;
pub const DEFAULT_HOVERFX_PERF_WORKER_LOCAL_LAYERS: bool = true;
pub const DEFAULT_HOVERFX_PERF_DIRTY_RECT_RENDERING: bool = true;
pub const DEFAULT_HOVERFX_PERF_SHADER_TEXTURE_CACHE: bool = true;
pub const DEFAULT_HOVERFX_PERF_DPR_CAP: f32 = 2.0;
pub const DEFAULT_HOVERFX_PERF_IDLE_RELEASE_TIMEOUT_MS: u16 = 1_200;
pub const MIN_HOVERFX_RADIUS_PX: u16 = 1;
pub const MAX_HOVERFX_RADIUS_PX: u16 = 2_000;
pub const MAX_HOVERFX_STRENGTH: f32 = 10.0;
pub const MAX_HOVERFX_MAX_ACTIVE_ELEMENTS: u16 = 64;
pub const MIN_HOVERFX_PERF_DPR_CAP: f32 = 1.0;
pub const MAX_HOVERFX_PERF_DPR_CAP: f32 = 3.0;
pub const MAX_HOVERFX_PERF_IDLE_RELEASE_TIMEOUT_MS: u16 = 60_000;
pub const MAX_HOVERFX_PERF_CANDIDATE_OBSERVER_MARGIN_PX: u16 = 5_000;
pub const DEFAULT_HOVERFX_TEXT_REVEAL_CHARSET: &str = "01";
pub const DEFAULT_HOVERFX_TEXT_REVEAL_CYCLE_SPEED_MS: u16 = 220;
pub const DEFAULT_HOVERFX_TEXT_REVEAL_DENSITY: f32 = 1.0;
pub const DEFAULT_HOVERFX_TEXT_REVEAL_FONT_SIZE_PX: u16 = 14;
pub const DEFAULT_HOVERFX_TEXT_REVEAL_GAP_PX: u16 = 6;
pub const DEFAULT_HOVERFX_TEXT_REVEAL_FONT_FAMILY: &str =
"ui-monospace, SFMono-Regular, Menlo, Consolas, monospace";
pub const DEFAULT_HOVERFX_TEXT_REVEAL_COLOR: &str =
"var(--dxh-binary-color, var(--dxt-accent, #22d3ee))";
pub const DEFAULT_HOVERFX_TEXT_REVEAL_TEXTFX_EFFECT: &str = "scramble";
pub const DEFAULT_HOVERFX_SAND_GRAIN_SIZE_PX: f32 = 1.15;
pub const DEFAULT_HOVERFX_SAND_GRAIN_DENSITY: f32 = 1.0;
pub const DEFAULT_HOVERFX_SAND_SHIMMER_DENSITY: f32 = 0.16;
pub const DEFAULT_HOVERFX_SAND_SHIMMER_STRENGTH: f32 = 0.75;
pub const DEFAULT_HOVERFX_SAND_SHIMMER_RADIUS_PX: f32 = 250.0;
pub const DEFAULT_HOVERFX_SAND_SPECULAR_STRENGTH: f32 = 0.85;
pub const DEFAULT_HOVERFX_SAND_ROUGHNESS: f32 = 0.42;
pub const DEFAULT_HOVERFX_SAND_ANIMATION_SPEED_MS: u16 = 900;
pub const DEFAULT_HOVERFX_SAND_COLOR: &str = "var(--dxh-sand-color, #d7b878)";
pub const DEFAULT_HOVERFX_SAND_HIGHLIGHT_COLOR: &str = "var(--dxh-sand-highlight, #fff4c2)";
pub const MIN_HOVERFX_SAND_ANIMATION_SPEED_MS: u16 = 16;
pub const MAX_HOVERFX_SAND_ANIMATION_SPEED_MS: u16 = 4_000;
pub const MIN_HOVERFX_TEXT_REVEAL_CYCLE_SPEED_MS: u16 = 16;
pub const MAX_HOVERFX_TEXT_REVEAL_CYCLE_SPEED_MS: u16 = 2_000;
pub const MAX_HOVERFX_TEXT_REVEAL_CHARSET_CHARS: usize = 64;
pub const MAX_HOVERFX_TEXT_REVEAL_FONT_SIZE_PX: u16 = 96;
pub const MAX_HOVERFX_TEXT_REVEAL_GAP_PX: u16 = 96;
pub const SUPPORTED_HOVERFX_TEXTFX_EFFECTS: [&str; 7] = [
"scramble",
"typewriter",
"wave",
"glitch",
"mask-reveal",
"highlight-sweep",
"gradient-shift",
];
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum HoverFxPreset {
Spotlight,
SoftGlow,
BorderTrace,
Sheen,
ColorWash,
BinaryReveal,
TextureReveal,
Sand,
}
impl Default for HoverFxPreset {
fn default() -> Self {
Self::Spotlight
}
}
impl HoverFxPreset {
pub const ALL: [Self; 8] = [
Self::Spotlight,
Self::SoftGlow,
Self::BorderTrace,
Self::Sheen,
Self::ColorWash,
Self::BinaryReveal,
Self::TextureReveal,
Self::Sand,
];
pub const fn all() -> &'static [Self; 8] {
&Self::ALL
}
pub const fn as_attr(self) -> &'static str {
match self {
Self::Spotlight => "spotlight",
Self::SoftGlow => "soft-glow",
Self::BorderTrace => "border-trace",
Self::Sheen => "sheen",
Self::ColorWash => "color-wash",
Self::BinaryReveal => "binary-reveal",
Self::TextureReveal => "texture-reveal",
Self::Sand => "sand",
}
}
pub const fn label(self) -> &'static str {
match self {
Self::Spotlight => "Spotlight",
Self::SoftGlow => "Soft glow",
Self::BorderTrace => "Border trace",
Self::Sheen => "Sheen",
Self::ColorWash => "Color wash",
Self::BinaryReveal => "Binary reveal",
Self::TextureReveal => "Texture reveal",
Self::Sand => "Sand",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum HoverFxTextAnimationSource {
HoverFx,
TextFx,
Auto,
}
impl Default for HoverFxTextAnimationSource {
fn default() -> Self {
Self::Auto
}
}
impl HoverFxTextAnimationSource {
pub const fn as_attr(self) -> &'static str {
match self {
Self::HoverFx => "hoverfx",
Self::TextFx => "textfx",
Self::Auto => "auto",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum HoverFxTextContrastMode {
Off,
Auto,
Darken,
Invert,
}
impl Default for HoverFxTextContrastMode {
fn default() -> Self {
Self::Off
}
}
impl HoverFxTextContrastMode {
pub const fn as_attr(self) -> &'static str {
match self {
Self::Off => "off",
Self::Auto => "auto",
Self::Darken => "darken",
Self::Invert => "invert",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum HoverFxTextRevealRenderer {
GlyphAtlas,
CanvasGrid,
}
impl Default for HoverFxTextRevealRenderer {
fn default() -> Self {
Self::GlyphAtlas
}
}
impl HoverFxTextRevealRenderer {
pub const fn as_attr(self) -> &'static str {
match self {
Self::GlyphAtlas => "glyph-atlas",
Self::CanvasGrid => "canvas-grid",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum HoverFxTextureRevealMode {
Auto,
Halftone,
StaticGrain,
}
impl Default for HoverFxTextureRevealMode {
fn default() -> Self {
Self::Auto
}
}
impl HoverFxTextureRevealMode {
pub const fn as_attr(self) -> &'static str {
match self {
Self::Auto => "auto",
Self::Halftone => "halftone",
Self::StaticGrain => "static-grain",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct HoverFxTextureRevealConfig {
pub mode: HoverFxTextureRevealMode,
}
impl Default for HoverFxTextureRevealConfig {
fn default() -> Self {
Self {
mode: HoverFxTextureRevealMode::Auto,
}
}
}
impl HoverFxTextureRevealConfig {
pub fn new() -> Self {
Self::default()
}
pub fn with_mode(mut self, mode: HoverFxTextureRevealMode) -> Self {
self.mode = mode;
self
}
pub fn to_json(&self) -> serde_json::Result<String> {
serde_json::to_string(self)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum HoverFxSandColorSource {
Custom,
Element,
}
impl Default for HoverFxSandColorSource {
fn default() -> Self {
Self::Custom
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct HoverFxSandConfig {
pub grain_size_px: f32,
pub grain_density: f32,
pub shimmer_density: f32,
pub shimmer_strength: f32,
#[serde(default = "default_hoverfx_sand_shimmer_radius_px")]
pub shimmer_radius_px: f32,
pub specular_strength: f32,
pub roughness: f32,
pub animation_speed_ms: u16,
#[serde(default, skip_serializing_if = "is_default_hoverfx_sand_color_source")]
pub color_source: HoverFxSandColorSource,
pub color: String,
pub highlight_color: String,
}
impl Default for HoverFxSandConfig {
fn default() -> Self {
Self {
grain_size_px: DEFAULT_HOVERFX_SAND_GRAIN_SIZE_PX,
grain_density: DEFAULT_HOVERFX_SAND_GRAIN_DENSITY,
shimmer_density: DEFAULT_HOVERFX_SAND_SHIMMER_DENSITY,
shimmer_strength: DEFAULT_HOVERFX_SAND_SHIMMER_STRENGTH,
shimmer_radius_px: DEFAULT_HOVERFX_SAND_SHIMMER_RADIUS_PX,
specular_strength: DEFAULT_HOVERFX_SAND_SPECULAR_STRENGTH,
roughness: DEFAULT_HOVERFX_SAND_ROUGHNESS,
animation_speed_ms: DEFAULT_HOVERFX_SAND_ANIMATION_SPEED_MS,
color_source: HoverFxSandColorSource::Custom,
color: DEFAULT_HOVERFX_SAND_COLOR.to_string(),
highlight_color: DEFAULT_HOVERFX_SAND_HIGHLIGHT_COLOR.to_string(),
}
}
}
impl HoverFxSandConfig {
pub fn new() -> Self {
Self::default()
}
pub fn with_grain_size_px(mut self, grain_size_px: f32) -> Self {
self.grain_size_px = grain_size_px;
self
}
pub fn with_grain_density(mut self, grain_density: f32) -> Self {
self.grain_density = grain_density;
self
}
pub fn with_shimmer_density(mut self, shimmer_density: f32) -> Self {
self.shimmer_density = shimmer_density;
self
}
pub fn with_shimmer_strength(mut self, shimmer_strength: f32) -> Self {
self.shimmer_strength = shimmer_strength;
self
}
pub fn with_shimmer_radius_px(mut self, shimmer_radius_px: f32) -> Self {
self.shimmer_radius_px = shimmer_radius_px;
self
}
pub fn with_specular_strength(mut self, specular_strength: f32) -> Self {
self.specular_strength = specular_strength;
self
}
pub fn with_roughness(mut self, roughness: f32) -> Self {
self.roughness = roughness;
self
}
pub fn with_animation_speed_ms(mut self, animation_speed_ms: u16) -> Self {
self.animation_speed_ms = animation_speed_ms;
self
}
pub fn with_color_source(mut self, color_source: HoverFxSandColorSource) -> Self {
self.color_source = color_source;
self
}
pub fn with_color(mut self, color: impl Into<String>) -> Self {
self.color = color.into();
self
}
pub fn with_highlight_color(mut self, highlight_color: impl Into<String>) -> Self {
self.highlight_color = highlight_color.into();
self
}
pub fn to_json(&self) -> serde_json::Result<String> {
serde_json::to_string(self)
}
}
fn default_hoverfx_sand_shimmer_radius_px() -> f32 {
DEFAULT_HOVERFX_SAND_SHIMMER_RADIUS_PX
}
fn is_default_hoverfx_sand_color_source(color_source: &HoverFxSandColorSource) -> bool {
*color_source == HoverFxSandColorSource::Custom
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum HoverFxShape {
Circle,
Square,
RoundedRect,
Polygon,
}
impl Default for HoverFxShape {
fn default() -> Self {
Self::Circle
}
}
impl HoverFxShape {
pub const ALL: [Self; 4] = [Self::Circle, Self::Square, Self::RoundedRect, Self::Polygon];
pub const fn all() -> &'static [Self; 4] {
&Self::ALL
}
pub const fn as_attr(self) -> &'static str {
match self {
Self::Circle => "circle",
Self::Square => "square",
Self::RoundedRect => "rounded-rect",
Self::Polygon => "polygon",
}
}
pub const fn label(self) -> &'static str {
match self {
Self::Circle => "Circle",
Self::Square => "Square",
Self::RoundedRect => "Rounded rect",
Self::Polygon => "Polygon",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum HoverFxFalloff {
Hard,
Linear,
Smooth,
Exponential,
}
impl Default for HoverFxFalloff {
fn default() -> Self {
Self::Smooth
}
}
impl HoverFxFalloff {
pub const ALL: [Self; 4] = [Self::Hard, Self::Linear, Self::Smooth, Self::Exponential];
pub const fn all() -> &'static [Self; 4] {
&Self::ALL
}
pub const fn as_attr(self) -> &'static str {
match self {
Self::Hard => "hard",
Self::Linear => "linear",
Self::Smooth => "smooth",
Self::Exponential => "exponential",
}
}
pub const fn label(self) -> &'static str {
match self {
Self::Hard => "Hard",
Self::Linear => "Linear",
Self::Smooth => "Smooth",
Self::Exponential => "Exponential",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum HoverFxRenderer {
WorkerFirst,
CssOnly,
Disabled,
}
impl Default for HoverFxRenderer {
fn default() -> Self {
Self::WorkerFirst
}
}
impl HoverFxRenderer {
pub const fn as_attr(self) -> &'static str {
match self {
Self::WorkerFirst => "worker-first",
Self::CssOnly => "css-only",
Self::Disabled => "disabled",
}
}
pub const fn uses_worker(self) -> bool {
matches!(self, Self::WorkerFirst)
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct HoverFxTextRevealConfig {
pub charset: String,
pub cycle: bool,
pub cycle_speed_ms: u16,
pub density: f32,
pub font_size_px: u16,
pub gap_px: u16,
pub font_family: String,
pub color: String,
pub animation_source: HoverFxTextAnimationSource,
#[serde(default)]
pub renderer: HoverFxTextRevealRenderer,
pub textfx_effect: String,
}
impl Default for HoverFxTextRevealConfig {
fn default() -> Self {
Self {
charset: DEFAULT_HOVERFX_TEXT_REVEAL_CHARSET.to_string(),
cycle: true,
cycle_speed_ms: DEFAULT_HOVERFX_TEXT_REVEAL_CYCLE_SPEED_MS,
density: DEFAULT_HOVERFX_TEXT_REVEAL_DENSITY,
font_size_px: DEFAULT_HOVERFX_TEXT_REVEAL_FONT_SIZE_PX,
gap_px: DEFAULT_HOVERFX_TEXT_REVEAL_GAP_PX,
font_family: DEFAULT_HOVERFX_TEXT_REVEAL_FONT_FAMILY.to_string(),
color: DEFAULT_HOVERFX_TEXT_REVEAL_COLOR.to_string(),
animation_source: HoverFxTextAnimationSource::Auto,
renderer: HoverFxTextRevealRenderer::GlyphAtlas,
textfx_effect: DEFAULT_HOVERFX_TEXT_REVEAL_TEXTFX_EFFECT.to_string(),
}
}
}
impl HoverFxTextRevealConfig {
pub fn new() -> Self {
Self::default()
}
pub fn with_charset(mut self, charset: impl Into<String>) -> Self {
self.charset = charset.into();
self
}
pub fn with_cycle(mut self, cycle: bool) -> Self {
self.cycle = cycle;
self
}
pub fn with_cycle_speed_ms(mut self, cycle_speed_ms: u16) -> Self {
self.cycle_speed_ms = cycle_speed_ms;
self
}
pub fn with_density(mut self, density: f32) -> Self {
self.density = density;
self
}
pub fn with_font_size_px(mut self, font_size_px: u16) -> Self {
self.font_size_px = font_size_px;
self
}
pub fn with_gap_px(mut self, gap_px: u16) -> Self {
self.gap_px = gap_px;
self
}
pub fn with_font_family(mut self, font_family: impl Into<String>) -> Self {
self.font_family = font_family.into();
self
}
pub fn with_color(mut self, color: impl Into<String>) -> Self {
self.color = color.into();
self
}
pub fn with_animation_source(mut self, animation_source: HoverFxTextAnimationSource) -> Self {
self.animation_source = animation_source;
self
}
pub fn with_renderer(mut self, renderer: HoverFxTextRevealRenderer) -> Self {
self.renderer = renderer;
self
}
pub fn with_textfx_effect(mut self, textfx_effect: impl AsRef<str>) -> Self {
self.textfx_effect = hoverfx_id(textfx_effect);
self
}
#[cfg(feature = "textfx-interop")]
pub fn with_textfx_effect_enum(mut self, effect: dioxus_textfx_core::TextFxEffect) -> Self {
self.textfx_effect = effect.as_attr().to_string();
self
}
pub fn to_json(&self) -> serde_json::Result<String> {
serde_json::to_string(self)
}
pub fn to_textfx_config_json(
&self,
id: impl AsRef<str>,
text: impl AsRef<str>,
) -> serde_json::Result<String> {
serde_json::to_string(&serde_json::json!({
"id": hoverfx_id(id),
"text": text.as_ref(),
"effect": self.textfx_effect,
"timing": {
"durationMs": 640,
"speedMs": self.cycle_speed_ms,
"staggerMs": 16,
},
"split": "chars",
"trigger": "hover",
"charset": self.charset,
"reducedMotion": "instant",
"performanceProfile": "interactive",
}))
}
}
#[cfg(feature = "textfx-interop")]
impl From<dioxus_textfx_core::TextFxEffect> for HoverFxTextRevealConfig {
fn from(effect: dioxus_textfx_core::TextFxEffect) -> Self {
Self::default().with_textfx_effect_enum(effect)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum HoverFxValidationSeverity {
Error,
Warning,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum HoverFxValidationCode {
MissingDefaultEffect,
InvalidDefaultEffectId,
InvalidEffectId,
EmptyRuntimePath,
EmptyWorkerPath,
InvalidRadius,
InvalidStrength,
InvalidSmoothing,
InvalidMaxActiveElements,
InvalidCssVariableName,
UnsafeCssValue,
UnsafeCustomShapeValue,
InvalidTextRevealCharset,
InvalidTextRevealCycleSpeed,
InvalidTextRevealMetric,
UnsafeTextRevealCssValue,
UnsupportedTextFxEffect,
InvalidSandMetric,
InvalidSandAnimationSpeed,
UnsafeSandCssValue,
InvalidPerformanceMetric,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct HoverFxValidationIssue {
pub severity: HoverFxValidationSeverity,
pub code: HoverFxValidationCode,
pub message: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub field: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub effect: Option<String>,
}
impl HoverFxValidationIssue {
pub fn error(
code: HoverFxValidationCode,
field: impl Into<String>,
message: impl Into<String>,
) -> Self {
Self {
severity: HoverFxValidationSeverity::Error,
code,
message: message.into(),
field: Some(field.into()),
effect: None,
}
}
pub fn effect_error(
code: HoverFxValidationCode,
effect: impl Into<String>,
field: impl Into<String>,
message: impl Into<String>,
) -> Self {
Self {
severity: HoverFxValidationSeverity::Error,
code,
message: message.into(),
field: Some(field.into()),
effect: Some(effect.into()),
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct HoverFxValidationReport {
pub issues: Vec<HoverFxValidationIssue>,
}
impl HoverFxValidationReport {
pub fn is_valid(&self) -> bool {
self.issues
.iter()
.all(|issue| issue.severity != HoverFxValidationSeverity::Error)
}
pub fn errors(&self) -> impl Iterator<Item = &HoverFxValidationIssue> {
self.issues
.iter()
.filter(|issue| issue.severity == HoverFxValidationSeverity::Error)
}
pub fn warnings(&self) -> impl Iterator<Item = &HoverFxValidationIssue> {
self.issues
.iter()
.filter(|issue| issue.severity == HoverFxValidationSeverity::Warning)
}
pub fn push(&mut self, issue: HoverFxValidationIssue) {
self.issues.push(issue);
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct HoverFxDefinition {
pub id: String,
pub label: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub preset: Option<HoverFxPreset>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub radius_px: Option<u16>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub shape: Option<HoverFxShape>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub falloff: Option<HoverFxFalloff>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub strength: Option<f32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub smoothing: Option<f32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub custom_shape: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub text_reveal: Option<HoverFxTextRevealConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub texture_reveal: Option<HoverFxTextureRevealConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sand: Option<HoverFxSandConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub text_contrast: Option<HoverFxTextContrastMode>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub css_vars: BTreeMap<String, String>,
}
impl HoverFxDefinition {
pub fn new(id: impl AsRef<str>, label: impl Into<String>) -> Self {
Self {
id: hoverfx_id(id),
label: label.into(),
preset: None,
radius_px: None,
shape: None,
falloff: None,
strength: None,
smoothing: None,
custom_shape: None,
text_reveal: None,
texture_reveal: None,
sand: None,
text_contrast: None,
css_vars: BTreeMap::new(),
}
}
pub fn from_preset(preset: HoverFxPreset) -> Self {
match preset {
HoverFxPreset::Spotlight => Self::new(preset.as_attr(), preset.label())
.with_preset(preset)
.with_shape(HoverFxShape::Circle)
.with_falloff(HoverFxFalloff::Smooth)
.with_css_var("--dxh-color", "rgba(255,255,255,0.32)")
.with_css_var("--dxh-blend-mode", "screen"),
HoverFxPreset::SoftGlow => Self::new(preset.as_attr(), preset.label())
.with_preset(preset)
.with_radius_px(220)
.with_shape(HoverFxShape::Circle)
.with_falloff(HoverFxFalloff::Smooth)
.with_strength(0.85)
.with_css_var("--dxh-color", "rgba(56,189,248,0.26)")
.with_css_var("--dxh-blur", "28px"),
HoverFxPreset::BorderTrace => Self::new(preset.as_attr(), preset.label())
.with_preset(preset)
.with_shape(HoverFxShape::RoundedRect)
.with_falloff(HoverFxFalloff::Exponential)
.with_css_var("--dxh-border-color", "rgba(125,211,252,0.82)")
.with_css_var("--dxh-border-width", "1px"),
HoverFxPreset::Sheen => Self::new(preset.as_attr(), preset.label())
.with_preset(preset)
.with_shape(HoverFxShape::Square)
.with_falloff(HoverFxFalloff::Linear)
.with_css_var("--dxh-angle", "115deg")
.with_css_var("--dxh-color", "rgba(255,255,255,0.36)"),
HoverFxPreset::ColorWash => Self::new(preset.as_attr(), preset.label())
.with_preset(preset)
.with_radius_px(240)
.with_shape(HoverFxShape::Circle)
.with_falloff(HoverFxFalloff::Smooth)
.with_css_var("--dxh-color", "rgba(14,165,233,0.22)")
.with_css_var("--dxh-accent-color", "rgba(217,70,239,0.18)"),
HoverFxPreset::BinaryReveal => Self::new(preset.as_attr(), preset.label())
.with_preset(preset)
.with_radius_px(300)
.with_shape(HoverFxShape::Circle)
.with_falloff(HoverFxFalloff::Smooth)
.with_strength(1.15)
.with_text_reveal(HoverFxTextRevealConfig::default())
.with_css_var("--dxh-binary-color", DEFAULT_HOVERFX_TEXT_REVEAL_COLOR),
HoverFxPreset::TextureReveal => Self::new(preset.as_attr(), preset.label())
.with_preset(preset)
.with_radius_px(340)
.with_shape(HoverFxShape::Circle)
.with_falloff(HoverFxFalloff::Smooth)
.with_strength(1.1)
.with_texture_reveal(HoverFxTextureRevealConfig::default()),
HoverFxPreset::Sand => Self::new(preset.as_attr(), preset.label())
.with_preset(preset)
.with_radius_px(320)
.with_shape(HoverFxShape::Circle)
.with_falloff(HoverFxFalloff::Smooth)
.with_strength(1.1)
.with_sand(HoverFxSandConfig::default())
.with_css_var("--dxh-sand-color", "#d7b878")
.with_css_var("--dxh-sand-highlight", "#fff4c2"),
}
}
pub fn with_label(mut self, label: impl Into<String>) -> Self {
self.label = label.into();
self
}
pub fn with_preset(mut self, preset: HoverFxPreset) -> Self {
self.preset = Some(preset);
self
}
pub fn with_radius_px(mut self, radius_px: u16) -> Self {
self.radius_px = Some(radius_px);
self
}
pub fn with_shape(mut self, shape: HoverFxShape) -> Self {
self.shape = Some(shape);
self
}
pub fn with_falloff(mut self, falloff: HoverFxFalloff) -> Self {
self.falloff = Some(falloff);
self
}
pub fn with_strength(mut self, strength: f32) -> Self {
self.strength = Some(strength);
self
}
pub fn with_smoothing(mut self, smoothing: f32) -> Self {
self.smoothing = Some(smoothing);
self
}
pub fn with_custom_shape(mut self, custom_shape: impl Into<String>) -> Self {
self.custom_shape = Some(custom_shape.into());
self
}
pub fn with_text_reveal(mut self, text_reveal: HoverFxTextRevealConfig) -> Self {
self.text_reveal = Some(text_reveal);
self
}
pub fn with_texture_reveal(mut self, texture_reveal: HoverFxTextureRevealConfig) -> Self {
self.texture_reveal = Some(texture_reveal);
self
}
pub fn with_sand(mut self, sand: HoverFxSandConfig) -> Self {
self.sand = Some(sand);
self
}
pub fn with_text_contrast(mut self, text_contrast: HoverFxTextContrastMode) -> Self {
self.text_contrast = Some(text_contrast);
self
}
pub fn with_css_var(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
self.css_vars.insert(name.into(), value.into());
self
}
pub fn with_css_vars<I, K, V>(mut self, css_vars: I) -> Self
where
I: IntoIterator<Item = (K, V)>,
K: Into<String>,
V: Into<String>,
{
for (name, value) in css_vars {
self.css_vars.insert(name.into(), value.into());
}
self
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct HoverFxRegistry {
pub effects: Vec<HoverFxDefinition>,
}
impl Default for HoverFxRegistry {
fn default() -> Self {
Self::defaults()
}
}
impl HoverFxRegistry {
pub fn new() -> Self {
Self {
effects: Vec::new(),
}
}
pub fn defaults() -> Self {
let mut registry = Self::new();
for preset in HoverFxPreset::all() {
registry.insert_effect(HoverFxDefinition::from_preset(*preset));
}
registry
}
pub fn with_effect(mut self, effect: HoverFxDefinition) -> Self {
self.insert_effect(effect);
self
}
pub fn with_effects<I>(mut self, effects: I) -> Self
where
I: IntoIterator<Item = HoverFxDefinition>,
{
for effect in effects {
self.insert_effect(effect);
}
self
}
pub fn insert_effect(&mut self, effect: HoverFxDefinition) -> Option<HoverFxDefinition> {
if let Some(existing) = self
.effects
.iter_mut()
.find(|candidate| candidate.id == effect.id)
{
return Some(std::mem::replace(existing, effect));
}
self.effects.push(effect);
None
}
pub fn contains_effect(&self, id: impl AsRef<str>) -> bool {
let id = hoverfx_id(id);
self.effects.iter().any(|effect| effect.id == id)
}
pub fn effect(&self, id: impl AsRef<str>) -> Option<&HoverFxDefinition> {
let id = hoverfx_id(id);
self.effects.iter().find(|effect| effect.id == id)
}
pub fn effect_ids(&self) -> Vec<&str> {
self.effects
.iter()
.map(|effect| effect.id.as_str())
.collect()
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct HoverFxPerformanceConfig {
pub lazy_local_layers: bool,
pub worker_local_layers: bool,
pub dirty_rect_rendering: bool,
pub shader_texture_cache: bool,
pub dpr_cap: f32,
pub idle_release_timeout_ms: u16,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub candidate_observer_margin_px: Option<u16>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub motion_lane: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub motion_scope: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub view_transition_name_isolation: Option<String>,
}
impl Default for HoverFxPerformanceConfig {
fn default() -> Self {
Self {
lazy_local_layers: DEFAULT_HOVERFX_PERF_LAZY_LOCAL_LAYERS,
worker_local_layers: DEFAULT_HOVERFX_PERF_WORKER_LOCAL_LAYERS,
dirty_rect_rendering: DEFAULT_HOVERFX_PERF_DIRTY_RECT_RENDERING,
shader_texture_cache: DEFAULT_HOVERFX_PERF_SHADER_TEXTURE_CACHE,
dpr_cap: DEFAULT_HOVERFX_PERF_DPR_CAP,
idle_release_timeout_ms: DEFAULT_HOVERFX_PERF_IDLE_RELEASE_TIMEOUT_MS,
candidate_observer_margin_px: None,
motion_lane: None,
motion_scope: None,
view_transition_name_isolation: None,
}
}
}
impl HoverFxPerformanceConfig {
pub fn new() -> Self {
Self::default()
}
pub fn with_lazy_local_layers(mut self, lazy_local_layers: bool) -> Self {
self.lazy_local_layers = lazy_local_layers;
self
}
pub fn with_worker_local_layers(mut self, worker_local_layers: bool) -> Self {
self.worker_local_layers = worker_local_layers;
self
}
pub fn with_dirty_rect_rendering(mut self, dirty_rect_rendering: bool) -> Self {
self.dirty_rect_rendering = dirty_rect_rendering;
self
}
pub fn with_shader_texture_cache(mut self, shader_texture_cache: bool) -> Self {
self.shader_texture_cache = shader_texture_cache;
self
}
pub fn with_dpr_cap(mut self, dpr_cap: f32) -> Self {
self.dpr_cap = dpr_cap;
self
}
pub fn with_idle_release_timeout_ms(mut self, idle_release_timeout_ms: u16) -> Self {
self.idle_release_timeout_ms = idle_release_timeout_ms;
self
}
pub fn with_candidate_observer_margin_px(mut self, candidate_observer_margin_px: u16) -> Self {
self.candidate_observer_margin_px = Some(candidate_observer_margin_px);
self
}
#[cfg(feature = "viewtx-interop")]
pub fn with_viewtx_motion_policy(
mut self,
policy: &dioxus_viewtx_core::ViewMotionPolicy,
) -> Self {
self.motion_lane = Some(policy.lane.as_attr().to_string());
self.motion_scope = Some(policy.scope.as_attr().to_string());
self.view_transition_name_isolation =
Some(policy.view_transition_name_isolation.as_attr().to_string());
self
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct HoverFxConfig {
pub registry: HoverFxRegistry,
pub default_effect: String,
pub radius_px: u16,
pub shape: HoverFxShape,
pub falloff: HoverFxFalloff,
pub strength: f32,
pub smoothing: f32,
pub max_active_elements: u16,
pub renderer: HoverFxRenderer,
pub runtime_path: String,
pub worker_path: String,
#[serde(default)]
pub performance: HoverFxPerformanceConfig,
}
impl Default for HoverFxConfig {
fn default() -> Self {
Self::new()
}
}
impl HoverFxConfig {
pub fn new() -> Self {
Self {
registry: HoverFxRegistry::default(),
default_effect: HoverFxPreset::Spotlight.as_attr().to_string(),
radius_px: DEFAULT_HOVERFX_RADIUS_PX,
shape: HoverFxShape::Circle,
falloff: HoverFxFalloff::Smooth,
strength: DEFAULT_HOVERFX_STRENGTH,
smoothing: DEFAULT_HOVERFX_SMOOTHING,
max_active_elements: DEFAULT_HOVERFX_MAX_ACTIVE_ELEMENTS,
renderer: HoverFxRenderer::WorkerFirst,
runtime_path: DEFAULT_HOVERFX_RUNTIME_PATH.to_string(),
worker_path: DEFAULT_HOVERFX_WORKER_PATH.to_string(),
performance: HoverFxPerformanceConfig::default(),
}
}
pub fn with_registry(mut self, registry: HoverFxRegistry) -> Self {
self.registry = registry;
self
}
pub fn with_effect(mut self, effect: HoverFxDefinition) -> Self {
self.registry.insert_effect(effect);
self
}
pub fn with_effects<I>(mut self, effects: I) -> Self
where
I: IntoIterator<Item = HoverFxDefinition>,
{
for effect in effects {
self.registry.insert_effect(effect);
}
self
}
pub fn with_default_effect(mut self, default_effect: impl AsRef<str>) -> Self {
self.default_effect = hoverfx_id(default_effect);
self
}
pub fn with_radius_px(mut self, radius_px: u16) -> Self {
self.radius_px = radius_px;
self
}
pub fn with_shape(mut self, shape: HoverFxShape) -> Self {
self.shape = shape;
self
}
pub fn with_falloff(mut self, falloff: HoverFxFalloff) -> Self {
self.falloff = falloff;
self
}
pub fn with_strength(mut self, strength: f32) -> Self {
self.strength = strength;
self
}
pub fn with_smoothing(mut self, smoothing: f32) -> Self {
self.smoothing = smoothing;
self
}
pub fn with_max_active_elements(mut self, max_active_elements: u16) -> Self {
self.max_active_elements = max_active_elements;
self
}
pub fn with_renderer(mut self, renderer: HoverFxRenderer) -> Self {
self.renderer = renderer;
self
}
pub fn with_runtime_path(mut self, runtime_path: impl Into<String>) -> Self {
self.runtime_path = runtime_path.into();
self
}
pub fn with_worker_path(mut self, worker_path: impl Into<String>) -> Self {
self.worker_path = worker_path.into();
self
}
pub fn with_performance(mut self, performance: HoverFxPerformanceConfig) -> Self {
self.performance = performance;
self
}
pub fn with_lazy_local_layers(mut self, lazy_local_layers: bool) -> Self {
self.performance.lazy_local_layers = lazy_local_layers;
self
}
pub fn with_worker_local_layers(mut self, worker_local_layers: bool) -> Self {
self.performance.worker_local_layers = worker_local_layers;
self
}
pub fn with_dirty_rect_rendering(mut self, dirty_rect_rendering: bool) -> Self {
self.performance.dirty_rect_rendering = dirty_rect_rendering;
self
}
pub fn with_shader_texture_cache(mut self, shader_texture_cache: bool) -> Self {
self.performance.shader_texture_cache = shader_texture_cache;
self
}
pub fn with_dpr_cap(mut self, dpr_cap: f32) -> Self {
self.performance.dpr_cap = dpr_cap;
self
}
pub fn with_idle_release_timeout_ms(mut self, idle_release_timeout_ms: u16) -> Self {
self.performance.idle_release_timeout_ms = idle_release_timeout_ms;
self
}
#[cfg(feature = "viewtx-interop")]
pub fn with_viewtx_motion_policy(
mut self,
policy: &dioxus_viewtx_core::ViewMotionPolicy,
) -> Self {
self.performance = self.performance.with_viewtx_motion_policy(policy);
self
}
pub fn with_candidate_observer_margin_px(mut self, candidate_observer_margin_px: u16) -> Self {
self.performance.candidate_observer_margin_px = Some(candidate_observer_margin_px);
self
}
pub fn validate(&self) -> HoverFxValidationReport {
let mut report = HoverFxValidationReport::default();
validate_radius(self.radius_px, "radius_px", None, &mut report);
validate_strength(self.strength, "strength", None, &mut report);
validate_smoothing(self.smoothing, "smoothing", None, &mut report);
validate_performance(&self.performance, &mut report);
if self.max_active_elements == 0
|| self.max_active_elements > MAX_HOVERFX_MAX_ACTIVE_ELEMENTS
{
report.push(HoverFxValidationIssue::error(
HoverFxValidationCode::InvalidMaxActiveElements,
"max_active_elements",
format!(
"max active elements must be between 1 and {}",
MAX_HOVERFX_MAX_ACTIVE_ELEMENTS
),
));
}
if !is_hoverfx_id(&self.default_effect) {
report.push(HoverFxValidationIssue::error(
HoverFxValidationCode::InvalidDefaultEffectId,
"default_effect",
"default effect id must be a kebab-case hoverfx id",
));
}
if !self.registry.contains_effect(&self.default_effect) {
report.push(HoverFxValidationIssue::error(
HoverFxValidationCode::MissingDefaultEffect,
"default_effect",
format!("default effect `{}` is not registered", self.default_effect),
));
}
if self.renderer.uses_worker() {
if self.runtime_path.trim().is_empty() {
report.push(HoverFxValidationIssue::error(
HoverFxValidationCode::EmptyRuntimePath,
"runtime_path",
"worker-first hoverfx requires a runtime path",
));
}
if self.worker_path.trim().is_empty() {
report.push(HoverFxValidationIssue::error(
HoverFxValidationCode::EmptyWorkerPath,
"worker_path",
"worker-first hoverfx requires a worker path",
));
}
}
for effect in &self.registry.effects {
validate_effect(effect, &mut report);
}
report
}
}
fn validate_performance(
performance: &HoverFxPerformanceConfig,
report: &mut HoverFxValidationReport,
) {
if !performance.dpr_cap.is_finite()
|| !(MIN_HOVERFX_PERF_DPR_CAP..=MAX_HOVERFX_PERF_DPR_CAP).contains(&performance.dpr_cap)
{
push_numeric_issue(
HoverFxValidationCode::InvalidPerformanceMetric,
None,
"performance.dpr_cap",
format!(
"performance dpr cap must be finite and between {} and {}",
MIN_HOVERFX_PERF_DPR_CAP, MAX_HOVERFX_PERF_DPR_CAP
),
report,
);
}
if performance.idle_release_timeout_ms > MAX_HOVERFX_PERF_IDLE_RELEASE_TIMEOUT_MS {
push_numeric_issue(
HoverFxValidationCode::InvalidPerformanceMetric,
None,
"performance.idle_release_timeout_ms",
format!(
"idle release timeout must be no more than {}ms",
MAX_HOVERFX_PERF_IDLE_RELEASE_TIMEOUT_MS
),
report,
);
}
if let Some(margin) = performance.candidate_observer_margin_px {
if margin > MAX_HOVERFX_PERF_CANDIDATE_OBSERVER_MARGIN_PX {
push_numeric_issue(
HoverFxValidationCode::InvalidPerformanceMetric,
None,
"performance.candidate_observer_margin_px",
format!(
"candidate observer margin must be no more than {}px",
MAX_HOVERFX_PERF_CANDIDATE_OBSERVER_MARGIN_PX
),
report,
);
}
}
}
fn validate_effect(effect: &HoverFxDefinition, report: &mut HoverFxValidationReport) {
if !is_hoverfx_id(&effect.id) {
report.push(HoverFxValidationIssue::effect_error(
HoverFxValidationCode::InvalidEffectId,
effect.id.clone(),
"id",
"effect id must be a kebab-case hoverfx id",
));
}
if let Some(radius_px) = effect.radius_px {
validate_radius(radius_px, "radius_px", Some(&effect.id), report);
}
if let Some(strength) = effect.strength {
validate_strength(strength, "strength", Some(&effect.id), report);
}
if let Some(smoothing) = effect.smoothing {
validate_smoothing(smoothing, "smoothing", Some(&effect.id), report);
}
if let Some(custom_shape) = &effect.custom_shape {
if !is_safe_css_custom_value(custom_shape) {
report.push(HoverFxValidationIssue::effect_error(
HoverFxValidationCode::UnsafeCustomShapeValue,
effect.id.clone(),
"custom_shape",
"custom shape must be a safe CSS value",
));
}
}
if let Some(text_reveal) = &effect.text_reveal {
validate_text_reveal(text_reveal, &effect.id, report);
}
if let Some(sand) = &effect.sand {
validate_sand(sand, &effect.id, report);
}
for (name, value) in &effect.css_vars {
if !is_custom_property_name(name) {
report.push(HoverFxValidationIssue::effect_error(
HoverFxValidationCode::InvalidCssVariableName,
effect.id.clone(),
name.clone(),
"hoverfx CSS variables must be CSS custom properties",
));
}
if !is_safe_css_custom_value(value) {
report.push(HoverFxValidationIssue::effect_error(
HoverFxValidationCode::UnsafeCssValue,
effect.id.clone(),
name.clone(),
"hoverfx CSS variable values must not contain declarations, URLs, or scriptable protocols",
));
}
}
}
fn validate_sand(sand: &HoverFxSandConfig, effect: &str, report: &mut HoverFxValidationReport) {
for (field, value, min, max) in [
("sand.grain_size_px", sand.grain_size_px, 0.2, 12.0),
("sand.grain_density", sand.grain_density, 0.05, 8.0),
("sand.shimmer_density", sand.shimmer_density, 0.0, 2.0),
("sand.shimmer_strength", sand.shimmer_strength, 0.0, 4.0),
(
"sand.shimmer_radius_px",
sand.shimmer_radius_px,
1.0,
2_000.0,
),
("sand.specular_strength", sand.specular_strength, 0.0, 4.0),
("sand.roughness", sand.roughness, 0.0, 1.0),
] {
if !value.is_finite() || value < min || value > max {
report.push(HoverFxValidationIssue::effect_error(
HoverFxValidationCode::InvalidSandMetric,
effect,
field,
format!("sand metric must be finite and between {min} and {max}"),
));
}
}
if !(MIN_HOVERFX_SAND_ANIMATION_SPEED_MS..=MAX_HOVERFX_SAND_ANIMATION_SPEED_MS)
.contains(&sand.animation_speed_ms)
{
report.push(HoverFxValidationIssue::effect_error(
HoverFxValidationCode::InvalidSandAnimationSpeed,
effect,
"sand.animation_speed_ms",
format!(
"sand animation speed must be between {}ms and {}ms",
MIN_HOVERFX_SAND_ANIMATION_SPEED_MS, MAX_HOVERFX_SAND_ANIMATION_SPEED_MS
),
));
}
for (field, value) in [
("sand.color", sand.color.as_str()),
("sand.highlight_color", sand.highlight_color.as_str()),
] {
if !is_safe_css_custom_value(value) {
report.push(HoverFxValidationIssue::effect_error(
HoverFxValidationCode::UnsafeSandCssValue,
effect,
field,
"sand CSS values must not contain declarations, URLs, or scriptable protocols",
));
}
}
}
fn validate_text_reveal(
text_reveal: &HoverFxTextRevealConfig,
effect: &str,
report: &mut HoverFxValidationReport,
) {
let charset_len = text_reveal.charset.chars().count();
if charset_len == 0
|| charset_len > MAX_HOVERFX_TEXT_REVEAL_CHARSET_CHARS
|| text_reveal.charset.chars().any(char::is_control)
{
report.push(HoverFxValidationIssue::effect_error(
HoverFxValidationCode::InvalidTextRevealCharset,
effect,
"text_reveal.charset",
format!(
"text reveal charset must contain 1 to {} printable characters",
MAX_HOVERFX_TEXT_REVEAL_CHARSET_CHARS
),
));
}
if !(MIN_HOVERFX_TEXT_REVEAL_CYCLE_SPEED_MS..=MAX_HOVERFX_TEXT_REVEAL_CYCLE_SPEED_MS)
.contains(&text_reveal.cycle_speed_ms)
{
report.push(HoverFxValidationIssue::effect_error(
HoverFxValidationCode::InvalidTextRevealCycleSpeed,
effect,
"text_reveal.cycle_speed_ms",
format!(
"text reveal cycle speed must be between {}ms and {}ms",
MIN_HOVERFX_TEXT_REVEAL_CYCLE_SPEED_MS, MAX_HOVERFX_TEXT_REVEAL_CYCLE_SPEED_MS
),
));
}
if !text_reveal.density.is_finite() || !(0.05..=8.0).contains(&text_reveal.density) {
report.push(HoverFxValidationIssue::effect_error(
HoverFxValidationCode::InvalidTextRevealMetric,
effect,
"text_reveal.density",
"text reveal density must be finite and between 0.05 and 8.0",
));
}
if text_reveal.font_size_px == 0
|| text_reveal.font_size_px > MAX_HOVERFX_TEXT_REVEAL_FONT_SIZE_PX
|| text_reveal.gap_px > MAX_HOVERFX_TEXT_REVEAL_GAP_PX
{
report.push(HoverFxValidationIssue::effect_error(
HoverFxValidationCode::InvalidTextRevealMetric,
effect,
"text_reveal.font_size_px",
"text reveal font size and gap must stay within supported pixel ranges",
));
}
for (field, value) in [
("text_reveal.font_family", text_reveal.font_family.as_str()),
("text_reveal.color", text_reveal.color.as_str()),
] {
if !is_safe_css_custom_value(value) {
report.push(HoverFxValidationIssue::effect_error(
HoverFxValidationCode::UnsafeTextRevealCssValue,
effect,
field,
"text reveal CSS values must not contain declarations, URLs, or scriptable protocols",
));
}
}
if !is_supported_textfx_effect(&text_reveal.textfx_effect) {
report.push(HoverFxValidationIssue::effect_error(
HoverFxValidationCode::UnsupportedTextFxEffect,
effect,
"text_reveal.textfx_effect",
format!(
"textfx effect must be one of: {}",
SUPPORTED_HOVERFX_TEXTFX_EFFECTS.join(", ")
),
));
}
}
fn validate_radius(
radius_px: u16,
field: &str,
effect: Option<&str>,
report: &mut HoverFxValidationReport,
) {
if !(MIN_HOVERFX_RADIUS_PX..=MAX_HOVERFX_RADIUS_PX).contains(&radius_px) {
push_numeric_issue(
HoverFxValidationCode::InvalidRadius,
effect,
field,
format!(
"radius must be between {}px and {}px",
MIN_HOVERFX_RADIUS_PX, MAX_HOVERFX_RADIUS_PX
),
report,
);
}
}
fn validate_strength(
strength: f32,
field: &str,
effect: Option<&str>,
report: &mut HoverFxValidationReport,
) {
if !strength.is_finite() || !(0.0..=MAX_HOVERFX_STRENGTH).contains(&strength) {
push_numeric_issue(
HoverFxValidationCode::InvalidStrength,
effect,
field,
format!("strength must be finite and between 0.0 and {MAX_HOVERFX_STRENGTH}"),
report,
);
}
}
fn validate_smoothing(
smoothing: f32,
field: &str,
effect: Option<&str>,
report: &mut HoverFxValidationReport,
) {
if !smoothing.is_finite() || !(0.0..=1.0).contains(&smoothing) {
push_numeric_issue(
HoverFxValidationCode::InvalidSmoothing,
effect,
field,
"smoothing must be finite and between 0.0 and 1.0",
report,
);
}
}
fn push_numeric_issue(
code: HoverFxValidationCode,
effect: Option<&str>,
field: &str,
message: impl Into<String>,
report: &mut HoverFxValidationReport,
) {
let message = message.into();
match effect {
Some(effect) => report.push(HoverFxValidationIssue::effect_error(
code,
effect.to_string(),
field,
message,
)),
None => report.push(HoverFxValidationIssue::error(code, field, message)),
}
}
pub fn hoverfx_id(id: impl AsRef<str>) -> String {
let mut normalized = String::with_capacity(id.as_ref().len());
let mut previous_dash = false;
for ch in id.as_ref().trim().chars() {
if ch.is_ascii_alphanumeric() {
normalized.push(ch.to_ascii_lowercase());
previous_dash = false;
} else if matches!(ch, '-' | '_' | ' ' | '.' | ':' | '/') && !previous_dash {
normalized.push('-');
previous_dash = true;
}
}
while normalized.ends_with('-') {
normalized.pop();
}
if normalized.is_empty() {
"effect".to_string()
} else {
normalized
}
}
pub fn is_hoverfx_id(id: &str) -> bool {
let bytes = id.as_bytes();
if bytes.is_empty() || bytes.first() == Some(&b'-') || bytes.last() == Some(&b'-') {
return false;
}
let mut previous_dash = false;
for byte in bytes {
let valid = byte.is_ascii_lowercase() || byte.is_ascii_digit() || *byte == b'-';
if !valid {
return false;
}
if *byte == b'-' {
if previous_dash {
return false;
}
previous_dash = true;
} else {
previous_dash = false;
}
}
true
}
pub fn is_custom_property_name(name: &str) -> bool {
let Some(rest) = name.strip_prefix("--") else {
return false;
};
!rest.is_empty()
&& rest
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))
}
pub fn is_safe_css_custom_value(value: &str) -> bool {
let value = value.trim();
if value.is_empty() || value.len() > 512 {
return false;
}
if value
.chars()
.any(|ch| ch.is_control() || matches!(ch, '{' | '}' | ';' | '<' | '>'))
{
return false;
}
let lower = value.to_ascii_lowercase();
![
"url(",
"expression(",
"@import",
"javascript:",
"vbscript:",
"data:",
"</script",
]
.iter()
.any(|needle| lower.contains(needle))
}
pub fn is_supported_textfx_effect(effect: &str) -> bool {
let effect = hoverfx_id(effect);
SUPPORTED_HOVERFX_TEXTFX_EFFECTS
.iter()
.any(|supported| *supported == effect)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn defaults_are_worker_first_and_include_builtin_presets() {
let config = HoverFxConfig::default();
assert_eq!(config.renderer, HoverFxRenderer::WorkerFirst);
assert_eq!(config.radius_px, DEFAULT_HOVERFX_RADIUS_PX);
assert_eq!(config.falloff, HoverFxFalloff::Smooth);
assert_eq!(config.strength, DEFAULT_HOVERFX_STRENGTH);
assert_eq!(config.smoothing, DEFAULT_HOVERFX_SMOOTHING);
assert!(config.performance.lazy_local_layers);
assert!(config.performance.worker_local_layers);
assert!(config.performance.dirty_rect_rendering);
assert!(config.performance.shader_texture_cache);
assert_eq!(config.performance.dpr_cap, DEFAULT_HOVERFX_PERF_DPR_CAP);
assert_eq!(
config.performance.idle_release_timeout_ms,
DEFAULT_HOVERFX_PERF_IDLE_RELEASE_TIMEOUT_MS
);
assert_eq!(config.performance.candidate_observer_margin_px, None);
assert_eq!(
config.max_active_elements,
DEFAULT_HOVERFX_MAX_ACTIVE_ELEMENTS
);
for preset in HoverFxPreset::all() {
assert!(config.registry.contains_effect(preset.as_attr()));
}
assert!(config.validate().is_valid());
}
#[test]
fn preset_helpers_and_kebab_case_serialization_match_attrs() {
assert_eq!(HoverFxPreset::SoftGlow.as_attr(), "soft-glow");
assert_eq!(HoverFxPreset::BorderTrace.label(), "Border trace");
assert_eq!(
serde_json::to_value(HoverFxPreset::ColorWash).unwrap(),
json!("color-wash")
);
assert_eq!(
serde_json::to_value(HoverFxShape::RoundedRect).unwrap(),
json!("rounded-rect")
);
assert_eq!(
serde_json::to_value(HoverFxRenderer::WorkerFirst).unwrap(),
json!("worker-first")
);
assert_eq!(HoverFxPreset::BinaryReveal.as_attr(), "binary-reveal");
assert_eq!(HoverFxPreset::BinaryReveal.label(), "Binary reveal");
assert_eq!(HoverFxPreset::TextureReveal.as_attr(), "texture-reveal");
assert_eq!(HoverFxPreset::TextureReveal.label(), "Texture reveal");
assert_eq!(HoverFxPreset::Sand.as_attr(), "sand");
assert_eq!(HoverFxPreset::Sand.label(), "Sand");
assert_eq!(
serde_json::to_value(HoverFxPreset::Sand).unwrap(),
json!("sand")
);
assert_eq!(HoverFxTextContrastMode::Auto.as_attr(), "auto");
assert_eq!(HoverFxTextContrastMode::Darken.as_attr(), "darken");
assert_eq!(HoverFxTextContrastMode::Invert.as_attr(), "invert");
assert_eq!(
serde_json::to_value(HoverFxTextContrastMode::Invert).unwrap(),
json!("invert")
);
assert_eq!(
serde_json::to_value(HoverFxTextureRevealMode::StaticGrain).unwrap(),
json!("static-grain")
);
assert_eq!(HoverFxPreset::all().len(), 8);
}
#[test]
fn builder_overrides_serialize_camel_case() {
let config = HoverFxConfig::new()
.with_default_effect("brand-wash")
.with_radius_px(260)
.with_shape(HoverFxShape::Square)
.with_falloff(HoverFxFalloff::Exponential)
.with_strength(1.4)
.with_smoothing(0.25)
.with_max_active_elements(12)
.with_renderer(HoverFxRenderer::CssOnly)
.with_performance(
HoverFxPerformanceConfig::default()
.with_lazy_local_layers(false)
.with_worker_local_layers(false)
.with_dirty_rect_rendering(false)
.with_shader_texture_cache(false)
.with_dpr_cap(1.5)
.with_idle_release_timeout_ms(2400)
.with_candidate_observer_margin_px(720),
)
.with_runtime_path("/assets/custom-hoverfx.js")
.with_worker_path("/assets/custom-hoverfx-worker.js")
.with_effect(
HoverFxDefinition::new("brand-wash", "Brand wash")
.with_preset(HoverFxPreset::ColorWash)
.with_radius_px(300)
.with_text_contrast(HoverFxTextContrastMode::Auto)
.with_css_var("--dxh-color", "rgba(14,165,233,0.30)"),
);
assert!(config.validate().is_valid());
let json = serde_json::to_value(&config).unwrap();
assert_eq!(json["defaultEffect"], "brand-wash");
assert_eq!(json["radiusPx"], 260);
assert_eq!(json["maxActiveElements"], 12);
assert_eq!(json["renderer"], "css-only");
assert_eq!(json["performance"]["lazyLocalLayers"], false);
assert_eq!(json["performance"]["workerLocalLayers"], false);
assert_eq!(json["performance"]["dirtyRectRendering"], false);
assert_eq!(json["performance"]["shaderTextureCache"], false);
assert_eq!(json["performance"]["dprCap"], 1.5);
assert_eq!(json["performance"]["idleReleaseTimeoutMs"], 2400);
assert_eq!(json["performance"]["candidateObserverMarginPx"], 720);
assert_eq!(json["registry"]["effects"][8]["preset"], "color-wash");
assert_eq!(json["registry"]["effects"][8]["textContrast"], "auto");
}
#[test]
fn validation_reports_invalid_config_and_effect_values() {
let mut config = HoverFxConfig::new()
.with_default_effect("missing")
.with_radius_px(0)
.with_strength(f32::INFINITY)
.with_smoothing(1.4)
.with_max_active_elements(0)
.with_dpr_cap(4.0)
.with_runtime_path("")
.with_worker_path("");
config.registry.effects.push(HoverFxDefinition {
id: "Bad Id".to_string(),
label: "Bad".to_string(),
preset: None,
radius_px: Some(0),
shape: Some(HoverFxShape::Polygon),
falloff: Some(HoverFxFalloff::Hard),
strength: Some(-1.0),
smoothing: Some(f32::NAN),
custom_shape: Some("polygon(0 0, url(javascript:bad))".to_string()),
text_reveal: Some(
HoverFxTextRevealConfig::default()
.with_charset("")
.with_cycle_speed_ms(1)
.with_density(f32::NAN)
.with_font_size_px(0)
.with_color("url(javascript:bad)")
.with_textfx_effect("fade"),
),
texture_reveal: None,
sand: Some(
HoverFxSandConfig::default()
.with_grain_size_px(0.0)
.with_shimmer_density(f32::NAN)
.with_shimmer_radius_px(0.0)
.with_animation_speed_ms(1)
.with_color("url(javascript:bad)"),
),
text_contrast: Some(HoverFxTextContrastMode::Invert),
css_vars: BTreeMap::from([
("color".to_string(), "red".to_string()),
(
"--dxh-bg".to_string(),
"url(javascript:alert(1))".to_string(),
),
]),
});
let report = config.validate();
let codes: Vec<_> = report.errors().map(|issue| issue.code).collect();
assert!(codes.contains(&HoverFxValidationCode::MissingDefaultEffect));
assert!(codes.contains(&HoverFxValidationCode::InvalidEffectId));
assert!(codes.contains(&HoverFxValidationCode::EmptyRuntimePath));
assert!(codes.contains(&HoverFxValidationCode::EmptyWorkerPath));
assert!(codes.contains(&HoverFxValidationCode::InvalidRadius));
assert!(codes.contains(&HoverFxValidationCode::InvalidStrength));
assert!(codes.contains(&HoverFxValidationCode::InvalidSmoothing));
assert!(codes.contains(&HoverFxValidationCode::InvalidMaxActiveElements));
assert!(codes.contains(&HoverFxValidationCode::InvalidCssVariableName));
assert!(codes.contains(&HoverFxValidationCode::UnsafeCssValue));
assert!(codes.contains(&HoverFxValidationCode::UnsafeCustomShapeValue));
assert!(codes.contains(&HoverFxValidationCode::InvalidTextRevealCharset));
assert!(codes.contains(&HoverFxValidationCode::InvalidTextRevealCycleSpeed));
assert!(codes.contains(&HoverFxValidationCode::InvalidTextRevealMetric));
assert!(codes.contains(&HoverFxValidationCode::UnsafeTextRevealCssValue));
assert!(codes.contains(&HoverFxValidationCode::UnsupportedTextFxEffect));
assert!(codes.contains(&HoverFxValidationCode::InvalidSandMetric));
assert!(codes.contains(&HoverFxValidationCode::InvalidSandAnimationSpeed));
assert!(codes.contains(&HoverFxValidationCode::UnsafeSandCssValue));
assert!(codes.contains(&HoverFxValidationCode::InvalidPerformanceMetric));
}
#[test]
fn binary_reveal_defaults_and_textfx_bridge_serialize() {
let definition = HoverFxDefinition::from_preset(HoverFxPreset::BinaryReveal);
let text_reveal = definition.text_reveal.expect("binary reveal config");
assert_eq!(definition.radius_px, Some(300));
assert_eq!(definition.shape, Some(HoverFxShape::Circle));
assert_eq!(definition.falloff, Some(HoverFxFalloff::Smooth));
assert_eq!(definition.strength, Some(1.15));
assert_eq!(text_reveal.charset, "01");
assert_eq!(text_reveal.cycle_speed_ms, 220);
assert_eq!(
text_reveal.animation_source,
HoverFxTextAnimationSource::Auto
);
assert_eq!(text_reveal.renderer, HoverFxTextRevealRenderer::GlyphAtlas);
assert_eq!(text_reveal.textfx_effect, "scramble");
assert_eq!(
HoverFxTextRevealRenderer::GlyphAtlas.as_attr(),
"glyph-atlas"
);
assert_eq!(
HoverFxTextRevealRenderer::CanvasGrid.as_attr(),
"canvas-grid"
);
assert!(is_supported_textfx_effect("gradient-shift"));
assert!(!is_supported_textfx_effect("fade"));
let json = text_reveal.to_json().unwrap();
assert!(json.contains(r#""animationSource":"auto""#));
assert!(json.contains(r#""renderer":"glyph-atlas""#));
assert!(
HoverFxTextRevealConfig::default()
.with_renderer(HoverFxTextRevealRenderer::CanvasGrid)
.to_json()
.unwrap()
.contains(r#""renderer":"canvas-grid""#)
);
let textfx = text_reveal
.to_textfx_config_json("binary-demo", "0101")
.unwrap();
assert!(textfx.contains(r#""effect":"scramble""#));
assert!(textfx.contains(r#""charset":"01""#));
assert!(textfx.contains(r#""speedMs":220"#));
}
#[test]
fn texture_reveal_defaults_and_builder_serialize() {
let definition = HoverFxDefinition::from_preset(HoverFxPreset::TextureReveal);
let texture_reveal = definition.texture_reveal.expect("texture reveal config");
assert_eq!(definition.radius_px, Some(340));
assert_eq!(definition.shape, Some(HoverFxShape::Circle));
assert_eq!(definition.falloff, Some(HoverFxFalloff::Smooth));
assert_eq!(definition.strength, Some(1.1));
assert_eq!(texture_reveal.mode, HoverFxTextureRevealMode::Auto);
assert_eq!(HoverFxTextureRevealMode::Halftone.as_attr(), "halftone");
assert_eq!(texture_reveal.to_json().unwrap(), r#"{"mode":"auto"}"#);
assert!(
HoverFxTextureRevealConfig::default()
.with_mode(HoverFxTextureRevealMode::StaticGrain)
.to_json()
.unwrap()
.contains(r#""mode":"static-grain""#)
);
}
#[test]
fn sand_defaults_and_builder_serialize() {
let definition = HoverFxDefinition::from_preset(HoverFxPreset::Sand);
let sand = definition.sand.expect("sand config");
assert_eq!(definition.radius_px, Some(320));
assert_eq!(definition.shape, Some(HoverFxShape::Circle));
assert_eq!(definition.falloff, Some(HoverFxFalloff::Smooth));
assert_eq!(definition.strength, Some(1.1));
assert_eq!(sand.grain_size_px, DEFAULT_HOVERFX_SAND_GRAIN_SIZE_PX);
assert_eq!(
sand.animation_speed_ms,
DEFAULT_HOVERFX_SAND_ANIMATION_SPEED_MS
);
assert_eq!(
sand.shimmer_radius_px,
DEFAULT_HOVERFX_SAND_SHIMMER_RADIUS_PX
);
assert_eq!(sand.color_source, HoverFxSandColorSource::Custom);
assert!(definition.css_vars.contains_key("--dxh-sand-color"));
let custom = HoverFxSandConfig::default()
.with_grain_size_px(1.6)
.with_grain_density(1.4)
.with_shimmer_density(0.22)
.with_shimmer_strength(0.9)
.with_shimmer_radius_px(280.0)
.with_specular_strength(1.2)
.with_roughness(0.36)
.with_animation_speed_ms(720)
.with_color_source(HoverFxSandColorSource::Element)
.with_color("#c9a96a")
.with_highlight_color("#fff2b8");
let json = serde_json::to_value(&custom).unwrap();
assert!((json["grainSizePx"].as_f64().unwrap() - 1.6).abs() < 0.001);
assert_eq!(json["shimmerRadiusPx"], 280.0);
assert_eq!(json["animationSpeedMs"], 720);
assert_eq!(json["colorSource"], "element");
assert_eq!(json["highlightColor"], "#fff2b8");
assert!(
HoverFxDefinition::new("custom-sand", "Custom sand")
.with_preset(HoverFxPreset::Sand)
.with_sand(custom)
.with_radius_px(360)
.with_strength(1.2)
.with_shape(HoverFxShape::Circle)
.with_falloff(HoverFxFalloff::Smooth)
.preset
.is_some()
);
}
#[test]
fn hoverfx_id_sanitizes_to_kebab_case() {
assert_eq!(hoverfx_id(" Soft Glow "), "soft-glow");
assert_eq!(hoverfx_id("brand.fx/accent"), "brand-fx-accent");
assert_eq!(hoverfx_id(""), "effect");
assert!(is_hoverfx_id("border-trace"));
assert!(!is_hoverfx_id("BorderTrace"));
assert!(!is_hoverfx_id("-bad"));
assert!(!is_hoverfx_id("bad--id"));
}
#[test]
fn css_safety_allows_values_but_rejects_declarations_and_urls() {
assert!(is_custom_property_name("--dxh-color"));
assert!(!is_custom_property_name("dxh-color"));
assert!(is_safe_css_custom_value("rgba(14,165,233,0.30)"));
assert!(is_safe_css_custom_value(
"polygon(0 0, 100% 0, 80% 100%, 0 90%)"
));
assert!(!is_safe_css_custom_value("red; background: blue"));
assert!(!is_safe_css_custom_value("url(https://example.com/a.png)"));
assert!(!is_safe_css_custom_value("javascript:alert(1)"));
assert!(!is_safe_css_custom_value("<script>alert(1)</script>"));
}
}