Skip to main content

dioxus_hoverfx_core/
lib.rs

1use std::collections::BTreeMap;
2
3use serde::{Deserialize, Serialize};
4
5pub const DEFAULT_HOVERFX_RUNTIME_BASE_PATH: &str = "/assets/dioxus-hoverfx.js";
6pub const DEFAULT_HOVERFX_WORKER_BASE_PATH: &str = "/assets/dioxus-hoverfx-worker.js";
7pub const DEFAULT_HOVERFX_RUNTIME_VERSION: &str = "1";
8pub const DEFAULT_HOVERFX_WORKER_VERSION: &str = "1";
9pub const DEFAULT_HOVERFX_RUNTIME_PATH: &str = "/assets/dioxus-hoverfx.js?v=1";
10pub const DEFAULT_HOVERFX_WORKER_PATH: &str = "/assets/dioxus-hoverfx-worker.js?v=1";
11pub const DEFAULT_HOVERFX_RADIUS_PX: u16 = 180;
12pub const DEFAULT_HOVERFX_STRENGTH: f32 = 1.0;
13pub const DEFAULT_HOVERFX_SMOOTHING: f32 = 0.18;
14pub const DEFAULT_HOVERFX_MAX_ACTIVE_ELEMENTS: u16 = 8;
15pub const DEFAULT_HOVERFX_PERF_LAZY_LOCAL_LAYERS: bool = true;
16pub const DEFAULT_HOVERFX_PERF_WORKER_LOCAL_LAYERS: bool = true;
17pub const DEFAULT_HOVERFX_PERF_DIRTY_RECT_RENDERING: bool = true;
18pub const DEFAULT_HOVERFX_PERF_SHADER_TEXTURE_CACHE: bool = true;
19pub const DEFAULT_HOVERFX_PERF_DPR_CAP: f32 = 2.0;
20pub const DEFAULT_HOVERFX_PERF_IDLE_RELEASE_TIMEOUT_MS: u16 = 1_200;
21pub const MIN_HOVERFX_RADIUS_PX: u16 = 1;
22pub const MAX_HOVERFX_RADIUS_PX: u16 = 2_000;
23pub const MAX_HOVERFX_STRENGTH: f32 = 10.0;
24pub const MAX_HOVERFX_MAX_ACTIVE_ELEMENTS: u16 = 64;
25pub const MIN_HOVERFX_PERF_DPR_CAP: f32 = 1.0;
26pub const MAX_HOVERFX_PERF_DPR_CAP: f32 = 3.0;
27pub const MAX_HOVERFX_PERF_IDLE_RELEASE_TIMEOUT_MS: u16 = 60_000;
28pub const MAX_HOVERFX_PERF_CANDIDATE_OBSERVER_MARGIN_PX: u16 = 5_000;
29pub const DEFAULT_HOVERFX_TEXT_REVEAL_CHARSET: &str = "01";
30pub const DEFAULT_HOVERFX_TEXT_REVEAL_CYCLE_SPEED_MS: u16 = 220;
31pub const DEFAULT_HOVERFX_TEXT_REVEAL_DENSITY: f32 = 1.0;
32pub const DEFAULT_HOVERFX_TEXT_REVEAL_FONT_SIZE_PX: u16 = 14;
33pub const DEFAULT_HOVERFX_TEXT_REVEAL_GAP_PX: u16 = 6;
34pub const DEFAULT_HOVERFX_TEXT_REVEAL_FONT_FAMILY: &str =
35    "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace";
36pub const DEFAULT_HOVERFX_TEXT_REVEAL_COLOR: &str =
37    "var(--dxh-binary-color, var(--dxt-accent, #22d3ee))";
38pub const DEFAULT_HOVERFX_TEXT_REVEAL_TEXTFX_EFFECT: &str = "scramble";
39pub const DEFAULT_HOVERFX_SAND_GRAIN_SIZE_PX: f32 = 1.15;
40pub const DEFAULT_HOVERFX_SAND_GRAIN_DENSITY: f32 = 1.0;
41pub const DEFAULT_HOVERFX_SAND_SHIMMER_DENSITY: f32 = 0.16;
42pub const DEFAULT_HOVERFX_SAND_SHIMMER_STRENGTH: f32 = 0.75;
43pub const DEFAULT_HOVERFX_SAND_SHIMMER_RADIUS_PX: f32 = 250.0;
44pub const DEFAULT_HOVERFX_SAND_SPECULAR_STRENGTH: f32 = 0.85;
45pub const DEFAULT_HOVERFX_SAND_ROUGHNESS: f32 = 0.42;
46pub const DEFAULT_HOVERFX_SAND_ANIMATION_SPEED_MS: u16 = 900;
47pub const DEFAULT_HOVERFX_SAND_COLOR: &str = "var(--dxh-sand-color, #d7b878)";
48pub const DEFAULT_HOVERFX_SAND_HIGHLIGHT_COLOR: &str = "var(--dxh-sand-highlight, #fff4c2)";
49pub const MIN_HOVERFX_SAND_ANIMATION_SPEED_MS: u16 = 16;
50pub const MAX_HOVERFX_SAND_ANIMATION_SPEED_MS: u16 = 4_000;
51pub const MIN_HOVERFX_TEXT_REVEAL_CYCLE_SPEED_MS: u16 = 16;
52pub const MAX_HOVERFX_TEXT_REVEAL_CYCLE_SPEED_MS: u16 = 2_000;
53pub const MAX_HOVERFX_TEXT_REVEAL_CHARSET_CHARS: usize = 64;
54pub const MAX_HOVERFX_TEXT_REVEAL_FONT_SIZE_PX: u16 = 96;
55pub const MAX_HOVERFX_TEXT_REVEAL_GAP_PX: u16 = 96;
56pub const SUPPORTED_HOVERFX_TEXTFX_EFFECTS: [&str; 7] = [
57    "scramble",
58    "typewriter",
59    "wave",
60    "glitch",
61    "mask-reveal",
62    "highlight-sweep",
63    "gradient-shift",
64];
65
66#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
67#[serde(rename_all = "kebab-case")]
68pub enum HoverFxPreset {
69    Spotlight,
70    SoftGlow,
71    BorderTrace,
72    Sheen,
73    ColorWash,
74    BinaryReveal,
75    TextureReveal,
76    Sand,
77}
78
79impl Default for HoverFxPreset {
80    fn default() -> Self {
81        Self::Spotlight
82    }
83}
84
85impl HoverFxPreset {
86    pub const ALL: [Self; 8] = [
87        Self::Spotlight,
88        Self::SoftGlow,
89        Self::BorderTrace,
90        Self::Sheen,
91        Self::ColorWash,
92        Self::BinaryReveal,
93        Self::TextureReveal,
94        Self::Sand,
95    ];
96
97    pub const fn all() -> &'static [Self; 8] {
98        &Self::ALL
99    }
100
101    pub const fn as_attr(self) -> &'static str {
102        match self {
103            Self::Spotlight => "spotlight",
104            Self::SoftGlow => "soft-glow",
105            Self::BorderTrace => "border-trace",
106            Self::Sheen => "sheen",
107            Self::ColorWash => "color-wash",
108            Self::BinaryReveal => "binary-reveal",
109            Self::TextureReveal => "texture-reveal",
110            Self::Sand => "sand",
111        }
112    }
113
114    pub const fn label(self) -> &'static str {
115        match self {
116            Self::Spotlight => "Spotlight",
117            Self::SoftGlow => "Soft glow",
118            Self::BorderTrace => "Border trace",
119            Self::Sheen => "Sheen",
120            Self::ColorWash => "Color wash",
121            Self::BinaryReveal => "Binary reveal",
122            Self::TextureReveal => "Texture reveal",
123            Self::Sand => "Sand",
124        }
125    }
126}
127
128#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
129#[serde(rename_all = "kebab-case")]
130pub enum HoverFxTextAnimationSource {
131    HoverFx,
132    TextFx,
133    Auto,
134}
135
136impl Default for HoverFxTextAnimationSource {
137    fn default() -> Self {
138        Self::Auto
139    }
140}
141
142impl HoverFxTextAnimationSource {
143    pub const fn as_attr(self) -> &'static str {
144        match self {
145            Self::HoverFx => "hoverfx",
146            Self::TextFx => "textfx",
147            Self::Auto => "auto",
148        }
149    }
150}
151
152#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
153#[serde(rename_all = "kebab-case")]
154pub enum HoverFxTextContrastMode {
155    Off,
156    Auto,
157    Darken,
158    Invert,
159}
160
161impl Default for HoverFxTextContrastMode {
162    fn default() -> Self {
163        Self::Off
164    }
165}
166
167impl HoverFxTextContrastMode {
168    pub const fn as_attr(self) -> &'static str {
169        match self {
170            Self::Off => "off",
171            Self::Auto => "auto",
172            Self::Darken => "darken",
173            Self::Invert => "invert",
174        }
175    }
176}
177
178#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
179#[serde(rename_all = "kebab-case")]
180pub enum HoverFxTextRevealRenderer {
181    GlyphAtlas,
182    CanvasGrid,
183}
184
185impl Default for HoverFxTextRevealRenderer {
186    fn default() -> Self {
187        Self::GlyphAtlas
188    }
189}
190
191impl HoverFxTextRevealRenderer {
192    pub const fn as_attr(self) -> &'static str {
193        match self {
194            Self::GlyphAtlas => "glyph-atlas",
195            Self::CanvasGrid => "canvas-grid",
196        }
197    }
198}
199
200#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
201#[serde(rename_all = "kebab-case")]
202pub enum HoverFxTextureRevealMode {
203    Auto,
204    Halftone,
205    StaticGrain,
206}
207
208impl Default for HoverFxTextureRevealMode {
209    fn default() -> Self {
210        Self::Auto
211    }
212}
213
214impl HoverFxTextureRevealMode {
215    pub const fn as_attr(self) -> &'static str {
216        match self {
217            Self::Auto => "auto",
218            Self::Halftone => "halftone",
219            Self::StaticGrain => "static-grain",
220        }
221    }
222}
223
224#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
225#[serde(rename_all = "camelCase")]
226pub struct HoverFxTextureRevealConfig {
227    pub mode: HoverFxTextureRevealMode,
228}
229
230impl Default for HoverFxTextureRevealConfig {
231    fn default() -> Self {
232        Self {
233            mode: HoverFxTextureRevealMode::Auto,
234        }
235    }
236}
237
238impl HoverFxTextureRevealConfig {
239    pub fn new() -> Self {
240        Self::default()
241    }
242
243    pub fn with_mode(mut self, mode: HoverFxTextureRevealMode) -> Self {
244        self.mode = mode;
245        self
246    }
247
248    pub fn to_json(&self) -> serde_json::Result<String> {
249        serde_json::to_string(self)
250    }
251}
252
253#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
254#[serde(rename_all = "kebab-case")]
255pub enum HoverFxSandColorSource {
256    Custom,
257    Element,
258}
259
260impl Default for HoverFxSandColorSource {
261    fn default() -> Self {
262        Self::Custom
263    }
264}
265
266#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
267#[serde(rename_all = "camelCase")]
268pub struct HoverFxSandConfig {
269    pub grain_size_px: f32,
270    pub grain_density: f32,
271    pub shimmer_density: f32,
272    pub shimmer_strength: f32,
273    #[serde(default = "default_hoverfx_sand_shimmer_radius_px")]
274    pub shimmer_radius_px: f32,
275    pub specular_strength: f32,
276    pub roughness: f32,
277    pub animation_speed_ms: u16,
278    #[serde(default, skip_serializing_if = "is_default_hoverfx_sand_color_source")]
279    pub color_source: HoverFxSandColorSource,
280    pub color: String,
281    pub highlight_color: String,
282}
283
284impl Default for HoverFxSandConfig {
285    fn default() -> Self {
286        Self {
287            grain_size_px: DEFAULT_HOVERFX_SAND_GRAIN_SIZE_PX,
288            grain_density: DEFAULT_HOVERFX_SAND_GRAIN_DENSITY,
289            shimmer_density: DEFAULT_HOVERFX_SAND_SHIMMER_DENSITY,
290            shimmer_strength: DEFAULT_HOVERFX_SAND_SHIMMER_STRENGTH,
291            shimmer_radius_px: DEFAULT_HOVERFX_SAND_SHIMMER_RADIUS_PX,
292            specular_strength: DEFAULT_HOVERFX_SAND_SPECULAR_STRENGTH,
293            roughness: DEFAULT_HOVERFX_SAND_ROUGHNESS,
294            animation_speed_ms: DEFAULT_HOVERFX_SAND_ANIMATION_SPEED_MS,
295            color_source: HoverFxSandColorSource::Custom,
296            color: DEFAULT_HOVERFX_SAND_COLOR.to_string(),
297            highlight_color: DEFAULT_HOVERFX_SAND_HIGHLIGHT_COLOR.to_string(),
298        }
299    }
300}
301
302impl HoverFxSandConfig {
303    pub fn new() -> Self {
304        Self::default()
305    }
306
307    pub fn with_grain_size_px(mut self, grain_size_px: f32) -> Self {
308        self.grain_size_px = grain_size_px;
309        self
310    }
311
312    pub fn with_grain_density(mut self, grain_density: f32) -> Self {
313        self.grain_density = grain_density;
314        self
315    }
316
317    pub fn with_shimmer_density(mut self, shimmer_density: f32) -> Self {
318        self.shimmer_density = shimmer_density;
319        self
320    }
321
322    pub fn with_shimmer_strength(mut self, shimmer_strength: f32) -> Self {
323        self.shimmer_strength = shimmer_strength;
324        self
325    }
326
327    pub fn with_shimmer_radius_px(mut self, shimmer_radius_px: f32) -> Self {
328        self.shimmer_radius_px = shimmer_radius_px;
329        self
330    }
331
332    pub fn with_specular_strength(mut self, specular_strength: f32) -> Self {
333        self.specular_strength = specular_strength;
334        self
335    }
336
337    pub fn with_roughness(mut self, roughness: f32) -> Self {
338        self.roughness = roughness;
339        self
340    }
341
342    pub fn with_animation_speed_ms(mut self, animation_speed_ms: u16) -> Self {
343        self.animation_speed_ms = animation_speed_ms;
344        self
345    }
346
347    pub fn with_color_source(mut self, color_source: HoverFxSandColorSource) -> Self {
348        self.color_source = color_source;
349        self
350    }
351
352    pub fn with_color(mut self, color: impl Into<String>) -> Self {
353        self.color = color.into();
354        self
355    }
356
357    pub fn with_highlight_color(mut self, highlight_color: impl Into<String>) -> Self {
358        self.highlight_color = highlight_color.into();
359        self
360    }
361
362    pub fn to_json(&self) -> serde_json::Result<String> {
363        serde_json::to_string(self)
364    }
365}
366
367fn default_hoverfx_sand_shimmer_radius_px() -> f32 {
368    DEFAULT_HOVERFX_SAND_SHIMMER_RADIUS_PX
369}
370
371fn is_default_hoverfx_sand_color_source(color_source: &HoverFxSandColorSource) -> bool {
372    *color_source == HoverFxSandColorSource::Custom
373}
374
375#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
376#[serde(rename_all = "kebab-case")]
377pub enum HoverFxShape {
378    Circle,
379    Square,
380    RoundedRect,
381    Polygon,
382}
383
384impl Default for HoverFxShape {
385    fn default() -> Self {
386        Self::Circle
387    }
388}
389
390impl HoverFxShape {
391    pub const ALL: [Self; 4] = [Self::Circle, Self::Square, Self::RoundedRect, Self::Polygon];
392
393    pub const fn all() -> &'static [Self; 4] {
394        &Self::ALL
395    }
396
397    pub const fn as_attr(self) -> &'static str {
398        match self {
399            Self::Circle => "circle",
400            Self::Square => "square",
401            Self::RoundedRect => "rounded-rect",
402            Self::Polygon => "polygon",
403        }
404    }
405
406    pub const fn label(self) -> &'static str {
407        match self {
408            Self::Circle => "Circle",
409            Self::Square => "Square",
410            Self::RoundedRect => "Rounded rect",
411            Self::Polygon => "Polygon",
412        }
413    }
414}
415
416#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
417#[serde(rename_all = "kebab-case")]
418pub enum HoverFxFalloff {
419    Hard,
420    Linear,
421    Smooth,
422    Exponential,
423}
424
425impl Default for HoverFxFalloff {
426    fn default() -> Self {
427        Self::Smooth
428    }
429}
430
431impl HoverFxFalloff {
432    pub const ALL: [Self; 4] = [Self::Hard, Self::Linear, Self::Smooth, Self::Exponential];
433
434    pub const fn all() -> &'static [Self; 4] {
435        &Self::ALL
436    }
437
438    pub const fn as_attr(self) -> &'static str {
439        match self {
440            Self::Hard => "hard",
441            Self::Linear => "linear",
442            Self::Smooth => "smooth",
443            Self::Exponential => "exponential",
444        }
445    }
446
447    pub const fn label(self) -> &'static str {
448        match self {
449            Self::Hard => "Hard",
450            Self::Linear => "Linear",
451            Self::Smooth => "Smooth",
452            Self::Exponential => "Exponential",
453        }
454    }
455}
456
457#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
458#[serde(rename_all = "kebab-case")]
459pub enum HoverFxRenderer {
460    WorkerFirst,
461    CssOnly,
462    Disabled,
463}
464
465impl Default for HoverFxRenderer {
466    fn default() -> Self {
467        Self::WorkerFirst
468    }
469}
470
471impl HoverFxRenderer {
472    pub const fn as_attr(self) -> &'static str {
473        match self {
474            Self::WorkerFirst => "worker-first",
475            Self::CssOnly => "css-only",
476            Self::Disabled => "disabled",
477        }
478    }
479
480    pub const fn uses_worker(self) -> bool {
481        matches!(self, Self::WorkerFirst)
482    }
483}
484
485#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
486#[serde(rename_all = "camelCase")]
487pub struct HoverFxTextRevealConfig {
488    pub charset: String,
489    pub cycle: bool,
490    pub cycle_speed_ms: u16,
491    pub density: f32,
492    pub font_size_px: u16,
493    pub gap_px: u16,
494    pub font_family: String,
495    pub color: String,
496    pub animation_source: HoverFxTextAnimationSource,
497    #[serde(default)]
498    pub renderer: HoverFxTextRevealRenderer,
499    pub textfx_effect: String,
500}
501
502impl Default for HoverFxTextRevealConfig {
503    fn default() -> Self {
504        Self {
505            charset: DEFAULT_HOVERFX_TEXT_REVEAL_CHARSET.to_string(),
506            cycle: true,
507            cycle_speed_ms: DEFAULT_HOVERFX_TEXT_REVEAL_CYCLE_SPEED_MS,
508            density: DEFAULT_HOVERFX_TEXT_REVEAL_DENSITY,
509            font_size_px: DEFAULT_HOVERFX_TEXT_REVEAL_FONT_SIZE_PX,
510            gap_px: DEFAULT_HOVERFX_TEXT_REVEAL_GAP_PX,
511            font_family: DEFAULT_HOVERFX_TEXT_REVEAL_FONT_FAMILY.to_string(),
512            color: DEFAULT_HOVERFX_TEXT_REVEAL_COLOR.to_string(),
513            animation_source: HoverFxTextAnimationSource::Auto,
514            renderer: HoverFxTextRevealRenderer::GlyphAtlas,
515            textfx_effect: DEFAULT_HOVERFX_TEXT_REVEAL_TEXTFX_EFFECT.to_string(),
516        }
517    }
518}
519
520impl HoverFxTextRevealConfig {
521    pub fn new() -> Self {
522        Self::default()
523    }
524
525    pub fn with_charset(mut self, charset: impl Into<String>) -> Self {
526        self.charset = charset.into();
527        self
528    }
529
530    pub fn with_cycle(mut self, cycle: bool) -> Self {
531        self.cycle = cycle;
532        self
533    }
534
535    pub fn with_cycle_speed_ms(mut self, cycle_speed_ms: u16) -> Self {
536        self.cycle_speed_ms = cycle_speed_ms;
537        self
538    }
539
540    pub fn with_density(mut self, density: f32) -> Self {
541        self.density = density;
542        self
543    }
544
545    pub fn with_font_size_px(mut self, font_size_px: u16) -> Self {
546        self.font_size_px = font_size_px;
547        self
548    }
549
550    pub fn with_gap_px(mut self, gap_px: u16) -> Self {
551        self.gap_px = gap_px;
552        self
553    }
554
555    pub fn with_font_family(mut self, font_family: impl Into<String>) -> Self {
556        self.font_family = font_family.into();
557        self
558    }
559
560    pub fn with_color(mut self, color: impl Into<String>) -> Self {
561        self.color = color.into();
562        self
563    }
564
565    pub fn with_animation_source(mut self, animation_source: HoverFxTextAnimationSource) -> Self {
566        self.animation_source = animation_source;
567        self
568    }
569
570    pub fn with_renderer(mut self, renderer: HoverFxTextRevealRenderer) -> Self {
571        self.renderer = renderer;
572        self
573    }
574
575    pub fn with_textfx_effect(mut self, textfx_effect: impl AsRef<str>) -> Self {
576        self.textfx_effect = hoverfx_id(textfx_effect);
577        self
578    }
579
580    #[cfg(feature = "textfx-interop")]
581    pub fn with_textfx_effect_enum(mut self, effect: dioxus_textfx_core::TextFxEffect) -> Self {
582        self.textfx_effect = effect.as_attr().to_string();
583        self
584    }
585
586    pub fn to_json(&self) -> serde_json::Result<String> {
587        serde_json::to_string(self)
588    }
589
590    pub fn to_textfx_config_json(
591        &self,
592        id: impl AsRef<str>,
593        text: impl AsRef<str>,
594    ) -> serde_json::Result<String> {
595        serde_json::to_string(&serde_json::json!({
596            "id": hoverfx_id(id),
597            "text": text.as_ref(),
598            "effect": self.textfx_effect,
599            "timing": {
600                "durationMs": 640,
601                "speedMs": self.cycle_speed_ms,
602                "staggerMs": 16,
603            },
604            "split": "chars",
605            "trigger": "hover",
606            "charset": self.charset,
607            "reducedMotion": "instant",
608            "performanceProfile": "interactive",
609        }))
610    }
611}
612
613#[cfg(feature = "textfx-interop")]
614impl From<dioxus_textfx_core::TextFxEffect> for HoverFxTextRevealConfig {
615    fn from(effect: dioxus_textfx_core::TextFxEffect) -> Self {
616        Self::default().with_textfx_effect_enum(effect)
617    }
618}
619
620#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
621#[serde(rename_all = "kebab-case")]
622pub enum HoverFxValidationSeverity {
623    Error,
624    Warning,
625}
626
627#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
628#[serde(rename_all = "kebab-case")]
629pub enum HoverFxValidationCode {
630    MissingDefaultEffect,
631    InvalidDefaultEffectId,
632    InvalidEffectId,
633    EmptyRuntimePath,
634    EmptyWorkerPath,
635    InvalidRadius,
636    InvalidStrength,
637    InvalidSmoothing,
638    InvalidMaxActiveElements,
639    InvalidCssVariableName,
640    UnsafeCssValue,
641    UnsafeCustomShapeValue,
642    InvalidTextRevealCharset,
643    InvalidTextRevealCycleSpeed,
644    InvalidTextRevealMetric,
645    UnsafeTextRevealCssValue,
646    UnsupportedTextFxEffect,
647    InvalidSandMetric,
648    InvalidSandAnimationSpeed,
649    UnsafeSandCssValue,
650    InvalidPerformanceMetric,
651}
652
653#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
654#[serde(rename_all = "camelCase")]
655pub struct HoverFxValidationIssue {
656    pub severity: HoverFxValidationSeverity,
657    pub code: HoverFxValidationCode,
658    pub message: String,
659    #[serde(default, skip_serializing_if = "Option::is_none")]
660    pub field: Option<String>,
661    #[serde(default, skip_serializing_if = "Option::is_none")]
662    pub effect: Option<String>,
663}
664
665impl HoverFxValidationIssue {
666    pub fn error(
667        code: HoverFxValidationCode,
668        field: impl Into<String>,
669        message: impl Into<String>,
670    ) -> Self {
671        Self {
672            severity: HoverFxValidationSeverity::Error,
673            code,
674            message: message.into(),
675            field: Some(field.into()),
676            effect: None,
677        }
678    }
679
680    pub fn effect_error(
681        code: HoverFxValidationCode,
682        effect: impl Into<String>,
683        field: impl Into<String>,
684        message: impl Into<String>,
685    ) -> Self {
686        Self {
687            severity: HoverFxValidationSeverity::Error,
688            code,
689            message: message.into(),
690            field: Some(field.into()),
691            effect: Some(effect.into()),
692        }
693    }
694}
695
696#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
697#[serde(rename_all = "camelCase")]
698pub struct HoverFxValidationReport {
699    pub issues: Vec<HoverFxValidationIssue>,
700}
701
702impl HoverFxValidationReport {
703    pub fn is_valid(&self) -> bool {
704        self.issues
705            .iter()
706            .all(|issue| issue.severity != HoverFxValidationSeverity::Error)
707    }
708
709    pub fn errors(&self) -> impl Iterator<Item = &HoverFxValidationIssue> {
710        self.issues
711            .iter()
712            .filter(|issue| issue.severity == HoverFxValidationSeverity::Error)
713    }
714
715    pub fn warnings(&self) -> impl Iterator<Item = &HoverFxValidationIssue> {
716        self.issues
717            .iter()
718            .filter(|issue| issue.severity == HoverFxValidationSeverity::Warning)
719    }
720
721    pub fn push(&mut self, issue: HoverFxValidationIssue) {
722        self.issues.push(issue);
723    }
724}
725
726#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
727#[serde(rename_all = "camelCase")]
728pub struct HoverFxDefinition {
729    pub id: String,
730    pub label: String,
731    #[serde(default, skip_serializing_if = "Option::is_none")]
732    pub preset: Option<HoverFxPreset>,
733    #[serde(default, skip_serializing_if = "Option::is_none")]
734    pub radius_px: Option<u16>,
735    #[serde(default, skip_serializing_if = "Option::is_none")]
736    pub shape: Option<HoverFxShape>,
737    #[serde(default, skip_serializing_if = "Option::is_none")]
738    pub falloff: Option<HoverFxFalloff>,
739    #[serde(default, skip_serializing_if = "Option::is_none")]
740    pub strength: Option<f32>,
741    #[serde(default, skip_serializing_if = "Option::is_none")]
742    pub smoothing: Option<f32>,
743    #[serde(default, skip_serializing_if = "Option::is_none")]
744    pub custom_shape: Option<String>,
745    #[serde(default, skip_serializing_if = "Option::is_none")]
746    pub text_reveal: Option<HoverFxTextRevealConfig>,
747    #[serde(default, skip_serializing_if = "Option::is_none")]
748    pub texture_reveal: Option<HoverFxTextureRevealConfig>,
749    #[serde(default, skip_serializing_if = "Option::is_none")]
750    pub sand: Option<HoverFxSandConfig>,
751    #[serde(default, skip_serializing_if = "Option::is_none")]
752    pub text_contrast: Option<HoverFxTextContrastMode>,
753    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
754    pub css_vars: BTreeMap<String, String>,
755}
756
757impl HoverFxDefinition {
758    pub fn new(id: impl AsRef<str>, label: impl Into<String>) -> Self {
759        Self {
760            id: hoverfx_id(id),
761            label: label.into(),
762            preset: None,
763            radius_px: None,
764            shape: None,
765            falloff: None,
766            strength: None,
767            smoothing: None,
768            custom_shape: None,
769            text_reveal: None,
770            texture_reveal: None,
771            sand: None,
772            text_contrast: None,
773            css_vars: BTreeMap::new(),
774        }
775    }
776
777    pub fn from_preset(preset: HoverFxPreset) -> Self {
778        match preset {
779            HoverFxPreset::Spotlight => Self::new(preset.as_attr(), preset.label())
780                .with_preset(preset)
781                .with_shape(HoverFxShape::Circle)
782                .with_falloff(HoverFxFalloff::Smooth)
783                .with_css_var("--dxh-color", "rgba(255,255,255,0.32)")
784                .with_css_var("--dxh-blend-mode", "screen"),
785            HoverFxPreset::SoftGlow => Self::new(preset.as_attr(), preset.label())
786                .with_preset(preset)
787                .with_radius_px(220)
788                .with_shape(HoverFxShape::Circle)
789                .with_falloff(HoverFxFalloff::Smooth)
790                .with_strength(0.85)
791                .with_css_var("--dxh-color", "rgba(56,189,248,0.26)")
792                .with_css_var("--dxh-blur", "28px"),
793            HoverFxPreset::BorderTrace => Self::new(preset.as_attr(), preset.label())
794                .with_preset(preset)
795                .with_shape(HoverFxShape::RoundedRect)
796                .with_falloff(HoverFxFalloff::Exponential)
797                .with_css_var("--dxh-border-color", "rgba(125,211,252,0.82)")
798                .with_css_var("--dxh-border-width", "1px"),
799            HoverFxPreset::Sheen => Self::new(preset.as_attr(), preset.label())
800                .with_preset(preset)
801                .with_shape(HoverFxShape::Square)
802                .with_falloff(HoverFxFalloff::Linear)
803                .with_css_var("--dxh-angle", "115deg")
804                .with_css_var("--dxh-color", "rgba(255,255,255,0.36)"),
805            HoverFxPreset::ColorWash => Self::new(preset.as_attr(), preset.label())
806                .with_preset(preset)
807                .with_radius_px(240)
808                .with_shape(HoverFxShape::Circle)
809                .with_falloff(HoverFxFalloff::Smooth)
810                .with_css_var("--dxh-color", "rgba(14,165,233,0.22)")
811                .with_css_var("--dxh-accent-color", "rgba(217,70,239,0.18)"),
812            HoverFxPreset::BinaryReveal => Self::new(preset.as_attr(), preset.label())
813                .with_preset(preset)
814                .with_radius_px(300)
815                .with_shape(HoverFxShape::Circle)
816                .with_falloff(HoverFxFalloff::Smooth)
817                .with_strength(1.15)
818                .with_text_reveal(HoverFxTextRevealConfig::default())
819                .with_css_var("--dxh-binary-color", DEFAULT_HOVERFX_TEXT_REVEAL_COLOR),
820            HoverFxPreset::TextureReveal => Self::new(preset.as_attr(), preset.label())
821                .with_preset(preset)
822                .with_radius_px(340)
823                .with_shape(HoverFxShape::Circle)
824                .with_falloff(HoverFxFalloff::Smooth)
825                .with_strength(1.1)
826                .with_texture_reveal(HoverFxTextureRevealConfig::default()),
827            HoverFxPreset::Sand => Self::new(preset.as_attr(), preset.label())
828                .with_preset(preset)
829                .with_radius_px(320)
830                .with_shape(HoverFxShape::Circle)
831                .with_falloff(HoverFxFalloff::Smooth)
832                .with_strength(1.1)
833                .with_sand(HoverFxSandConfig::default())
834                .with_css_var("--dxh-sand-color", "#d7b878")
835                .with_css_var("--dxh-sand-highlight", "#fff4c2"),
836        }
837    }
838
839    pub fn with_label(mut self, label: impl Into<String>) -> Self {
840        self.label = label.into();
841        self
842    }
843
844    pub fn with_preset(mut self, preset: HoverFxPreset) -> Self {
845        self.preset = Some(preset);
846        self
847    }
848
849    pub fn with_radius_px(mut self, radius_px: u16) -> Self {
850        self.radius_px = Some(radius_px);
851        self
852    }
853
854    pub fn with_shape(mut self, shape: HoverFxShape) -> Self {
855        self.shape = Some(shape);
856        self
857    }
858
859    pub fn with_falloff(mut self, falloff: HoverFxFalloff) -> Self {
860        self.falloff = Some(falloff);
861        self
862    }
863
864    pub fn with_strength(mut self, strength: f32) -> Self {
865        self.strength = Some(strength);
866        self
867    }
868
869    pub fn with_smoothing(mut self, smoothing: f32) -> Self {
870        self.smoothing = Some(smoothing);
871        self
872    }
873
874    pub fn with_custom_shape(mut self, custom_shape: impl Into<String>) -> Self {
875        self.custom_shape = Some(custom_shape.into());
876        self
877    }
878
879    pub fn with_text_reveal(mut self, text_reveal: HoverFxTextRevealConfig) -> Self {
880        self.text_reveal = Some(text_reveal);
881        self
882    }
883
884    pub fn with_texture_reveal(mut self, texture_reveal: HoverFxTextureRevealConfig) -> Self {
885        self.texture_reveal = Some(texture_reveal);
886        self
887    }
888
889    pub fn with_sand(mut self, sand: HoverFxSandConfig) -> Self {
890        self.sand = Some(sand);
891        self
892    }
893
894    pub fn with_text_contrast(mut self, text_contrast: HoverFxTextContrastMode) -> Self {
895        self.text_contrast = Some(text_contrast);
896        self
897    }
898
899    pub fn with_css_var(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
900        self.css_vars.insert(name.into(), value.into());
901        self
902    }
903
904    pub fn with_css_vars<I, K, V>(mut self, css_vars: I) -> Self
905    where
906        I: IntoIterator<Item = (K, V)>,
907        K: Into<String>,
908        V: Into<String>,
909    {
910        for (name, value) in css_vars {
911            self.css_vars.insert(name.into(), value.into());
912        }
913        self
914    }
915}
916
917#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
918#[serde(rename_all = "camelCase")]
919pub struct HoverFxRegistry {
920    pub effects: Vec<HoverFxDefinition>,
921}
922
923impl Default for HoverFxRegistry {
924    fn default() -> Self {
925        Self::defaults()
926    }
927}
928
929impl HoverFxRegistry {
930    pub fn new() -> Self {
931        Self {
932            effects: Vec::new(),
933        }
934    }
935
936    pub fn defaults() -> Self {
937        let mut registry = Self::new();
938        for preset in HoverFxPreset::all() {
939            registry.insert_effect(HoverFxDefinition::from_preset(*preset));
940        }
941        registry
942    }
943
944    pub fn with_effect(mut self, effect: HoverFxDefinition) -> Self {
945        self.insert_effect(effect);
946        self
947    }
948
949    pub fn with_effects<I>(mut self, effects: I) -> Self
950    where
951        I: IntoIterator<Item = HoverFxDefinition>,
952    {
953        for effect in effects {
954            self.insert_effect(effect);
955        }
956        self
957    }
958
959    pub fn insert_effect(&mut self, effect: HoverFxDefinition) -> Option<HoverFxDefinition> {
960        if let Some(existing) = self
961            .effects
962            .iter_mut()
963            .find(|candidate| candidate.id == effect.id)
964        {
965            return Some(std::mem::replace(existing, effect));
966        }
967        self.effects.push(effect);
968        None
969    }
970
971    pub fn contains_effect(&self, id: impl AsRef<str>) -> bool {
972        let id = hoverfx_id(id);
973        self.effects.iter().any(|effect| effect.id == id)
974    }
975
976    pub fn effect(&self, id: impl AsRef<str>) -> Option<&HoverFxDefinition> {
977        let id = hoverfx_id(id);
978        self.effects.iter().find(|effect| effect.id == id)
979    }
980
981    pub fn effect_ids(&self) -> Vec<&str> {
982        self.effects
983            .iter()
984            .map(|effect| effect.id.as_str())
985            .collect()
986    }
987}
988
989#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
990#[serde(rename_all = "camelCase")]
991pub struct HoverFxPerformanceConfig {
992    pub lazy_local_layers: bool,
993    pub worker_local_layers: bool,
994    pub dirty_rect_rendering: bool,
995    pub shader_texture_cache: bool,
996    pub dpr_cap: f32,
997    pub idle_release_timeout_ms: u16,
998    #[serde(default, skip_serializing_if = "Option::is_none")]
999    pub candidate_observer_margin_px: Option<u16>,
1000    #[serde(default, skip_serializing_if = "Option::is_none")]
1001    pub motion_lane: Option<String>,
1002    #[serde(default, skip_serializing_if = "Option::is_none")]
1003    pub motion_scope: Option<String>,
1004    #[serde(default, skip_serializing_if = "Option::is_none")]
1005    pub view_transition_name_isolation: Option<String>,
1006}
1007
1008impl Default for HoverFxPerformanceConfig {
1009    fn default() -> Self {
1010        Self {
1011            lazy_local_layers: DEFAULT_HOVERFX_PERF_LAZY_LOCAL_LAYERS,
1012            worker_local_layers: DEFAULT_HOVERFX_PERF_WORKER_LOCAL_LAYERS,
1013            dirty_rect_rendering: DEFAULT_HOVERFX_PERF_DIRTY_RECT_RENDERING,
1014            shader_texture_cache: DEFAULT_HOVERFX_PERF_SHADER_TEXTURE_CACHE,
1015            dpr_cap: DEFAULT_HOVERFX_PERF_DPR_CAP,
1016            idle_release_timeout_ms: DEFAULT_HOVERFX_PERF_IDLE_RELEASE_TIMEOUT_MS,
1017            candidate_observer_margin_px: None,
1018            motion_lane: None,
1019            motion_scope: None,
1020            view_transition_name_isolation: None,
1021        }
1022    }
1023}
1024
1025impl HoverFxPerformanceConfig {
1026    pub fn new() -> Self {
1027        Self::default()
1028    }
1029
1030    pub fn with_lazy_local_layers(mut self, lazy_local_layers: bool) -> Self {
1031        self.lazy_local_layers = lazy_local_layers;
1032        self
1033    }
1034
1035    pub fn with_worker_local_layers(mut self, worker_local_layers: bool) -> Self {
1036        self.worker_local_layers = worker_local_layers;
1037        self
1038    }
1039
1040    pub fn with_dirty_rect_rendering(mut self, dirty_rect_rendering: bool) -> Self {
1041        self.dirty_rect_rendering = dirty_rect_rendering;
1042        self
1043    }
1044
1045    pub fn with_shader_texture_cache(mut self, shader_texture_cache: bool) -> Self {
1046        self.shader_texture_cache = shader_texture_cache;
1047        self
1048    }
1049
1050    pub fn with_dpr_cap(mut self, dpr_cap: f32) -> Self {
1051        self.dpr_cap = dpr_cap;
1052        self
1053    }
1054
1055    pub fn with_idle_release_timeout_ms(mut self, idle_release_timeout_ms: u16) -> Self {
1056        self.idle_release_timeout_ms = idle_release_timeout_ms;
1057        self
1058    }
1059
1060    pub fn with_candidate_observer_margin_px(mut self, candidate_observer_margin_px: u16) -> Self {
1061        self.candidate_observer_margin_px = Some(candidate_observer_margin_px);
1062        self
1063    }
1064
1065    #[cfg(feature = "viewtx-interop")]
1066    pub fn with_viewtx_motion_policy(
1067        mut self,
1068        policy: &dioxus_viewtx_core::ViewMotionPolicy,
1069    ) -> Self {
1070        self.motion_lane = Some(policy.lane.as_attr().to_string());
1071        self.motion_scope = Some(policy.scope.as_attr().to_string());
1072        self.view_transition_name_isolation =
1073            Some(policy.view_transition_name_isolation.as_attr().to_string());
1074        self
1075    }
1076}
1077
1078#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1079#[serde(rename_all = "camelCase")]
1080pub struct HoverFxConfig {
1081    pub registry: HoverFxRegistry,
1082    pub default_effect: String,
1083    pub radius_px: u16,
1084    pub shape: HoverFxShape,
1085    pub falloff: HoverFxFalloff,
1086    pub strength: f32,
1087    pub smoothing: f32,
1088    pub max_active_elements: u16,
1089    pub renderer: HoverFxRenderer,
1090    pub runtime_path: String,
1091    pub worker_path: String,
1092    #[serde(default)]
1093    pub performance: HoverFxPerformanceConfig,
1094}
1095
1096impl Default for HoverFxConfig {
1097    fn default() -> Self {
1098        Self::new()
1099    }
1100}
1101
1102impl HoverFxConfig {
1103    pub fn new() -> Self {
1104        Self {
1105            registry: HoverFxRegistry::default(),
1106            default_effect: HoverFxPreset::Spotlight.as_attr().to_string(),
1107            radius_px: DEFAULT_HOVERFX_RADIUS_PX,
1108            shape: HoverFxShape::Circle,
1109            falloff: HoverFxFalloff::Smooth,
1110            strength: DEFAULT_HOVERFX_STRENGTH,
1111            smoothing: DEFAULT_HOVERFX_SMOOTHING,
1112            max_active_elements: DEFAULT_HOVERFX_MAX_ACTIVE_ELEMENTS,
1113            renderer: HoverFxRenderer::WorkerFirst,
1114            runtime_path: DEFAULT_HOVERFX_RUNTIME_PATH.to_string(),
1115            worker_path: DEFAULT_HOVERFX_WORKER_PATH.to_string(),
1116            performance: HoverFxPerformanceConfig::default(),
1117        }
1118    }
1119
1120    pub fn with_registry(mut self, registry: HoverFxRegistry) -> Self {
1121        self.registry = registry;
1122        self
1123    }
1124
1125    pub fn with_effect(mut self, effect: HoverFxDefinition) -> Self {
1126        self.registry.insert_effect(effect);
1127        self
1128    }
1129
1130    pub fn with_effects<I>(mut self, effects: I) -> Self
1131    where
1132        I: IntoIterator<Item = HoverFxDefinition>,
1133    {
1134        for effect in effects {
1135            self.registry.insert_effect(effect);
1136        }
1137        self
1138    }
1139
1140    pub fn with_default_effect(mut self, default_effect: impl AsRef<str>) -> Self {
1141        self.default_effect = hoverfx_id(default_effect);
1142        self
1143    }
1144
1145    pub fn with_radius_px(mut self, radius_px: u16) -> Self {
1146        self.radius_px = radius_px;
1147        self
1148    }
1149
1150    pub fn with_shape(mut self, shape: HoverFxShape) -> Self {
1151        self.shape = shape;
1152        self
1153    }
1154
1155    pub fn with_falloff(mut self, falloff: HoverFxFalloff) -> Self {
1156        self.falloff = falloff;
1157        self
1158    }
1159
1160    pub fn with_strength(mut self, strength: f32) -> Self {
1161        self.strength = strength;
1162        self
1163    }
1164
1165    pub fn with_smoothing(mut self, smoothing: f32) -> Self {
1166        self.smoothing = smoothing;
1167        self
1168    }
1169
1170    pub fn with_max_active_elements(mut self, max_active_elements: u16) -> Self {
1171        self.max_active_elements = max_active_elements;
1172        self
1173    }
1174
1175    pub fn with_renderer(mut self, renderer: HoverFxRenderer) -> Self {
1176        self.renderer = renderer;
1177        self
1178    }
1179
1180    pub fn with_runtime_path(mut self, runtime_path: impl Into<String>) -> Self {
1181        self.runtime_path = runtime_path.into();
1182        self
1183    }
1184
1185    pub fn with_worker_path(mut self, worker_path: impl Into<String>) -> Self {
1186        self.worker_path = worker_path.into();
1187        self
1188    }
1189
1190    pub fn with_performance(mut self, performance: HoverFxPerformanceConfig) -> Self {
1191        self.performance = performance;
1192        self
1193    }
1194
1195    pub fn with_lazy_local_layers(mut self, lazy_local_layers: bool) -> Self {
1196        self.performance.lazy_local_layers = lazy_local_layers;
1197        self
1198    }
1199
1200    pub fn with_worker_local_layers(mut self, worker_local_layers: bool) -> Self {
1201        self.performance.worker_local_layers = worker_local_layers;
1202        self
1203    }
1204
1205    pub fn with_dirty_rect_rendering(mut self, dirty_rect_rendering: bool) -> Self {
1206        self.performance.dirty_rect_rendering = dirty_rect_rendering;
1207        self
1208    }
1209
1210    pub fn with_shader_texture_cache(mut self, shader_texture_cache: bool) -> Self {
1211        self.performance.shader_texture_cache = shader_texture_cache;
1212        self
1213    }
1214
1215    pub fn with_dpr_cap(mut self, dpr_cap: f32) -> Self {
1216        self.performance.dpr_cap = dpr_cap;
1217        self
1218    }
1219
1220    pub fn with_idle_release_timeout_ms(mut self, idle_release_timeout_ms: u16) -> Self {
1221        self.performance.idle_release_timeout_ms = idle_release_timeout_ms;
1222        self
1223    }
1224
1225    #[cfg(feature = "viewtx-interop")]
1226    pub fn with_viewtx_motion_policy(
1227        mut self,
1228        policy: &dioxus_viewtx_core::ViewMotionPolicy,
1229    ) -> Self {
1230        self.performance = self.performance.with_viewtx_motion_policy(policy);
1231        self
1232    }
1233
1234    pub fn with_candidate_observer_margin_px(mut self, candidate_observer_margin_px: u16) -> Self {
1235        self.performance.candidate_observer_margin_px = Some(candidate_observer_margin_px);
1236        self
1237    }
1238
1239    pub fn validate(&self) -> HoverFxValidationReport {
1240        let mut report = HoverFxValidationReport::default();
1241
1242        validate_radius(self.radius_px, "radius_px", None, &mut report);
1243        validate_strength(self.strength, "strength", None, &mut report);
1244        validate_smoothing(self.smoothing, "smoothing", None, &mut report);
1245        validate_performance(&self.performance, &mut report);
1246        if self.max_active_elements == 0
1247            || self.max_active_elements > MAX_HOVERFX_MAX_ACTIVE_ELEMENTS
1248        {
1249            report.push(HoverFxValidationIssue::error(
1250                HoverFxValidationCode::InvalidMaxActiveElements,
1251                "max_active_elements",
1252                format!(
1253                    "max active elements must be between 1 and {}",
1254                    MAX_HOVERFX_MAX_ACTIVE_ELEMENTS
1255                ),
1256            ));
1257        }
1258        if !is_hoverfx_id(&self.default_effect) {
1259            report.push(HoverFxValidationIssue::error(
1260                HoverFxValidationCode::InvalidDefaultEffectId,
1261                "default_effect",
1262                "default effect id must be a kebab-case hoverfx id",
1263            ));
1264        }
1265        if !self.registry.contains_effect(&self.default_effect) {
1266            report.push(HoverFxValidationIssue::error(
1267                HoverFxValidationCode::MissingDefaultEffect,
1268                "default_effect",
1269                format!("default effect `{}` is not registered", self.default_effect),
1270            ));
1271        }
1272        if self.renderer.uses_worker() {
1273            if self.runtime_path.trim().is_empty() {
1274                report.push(HoverFxValidationIssue::error(
1275                    HoverFxValidationCode::EmptyRuntimePath,
1276                    "runtime_path",
1277                    "worker-first hoverfx requires a runtime path",
1278                ));
1279            }
1280            if self.worker_path.trim().is_empty() {
1281                report.push(HoverFxValidationIssue::error(
1282                    HoverFxValidationCode::EmptyWorkerPath,
1283                    "worker_path",
1284                    "worker-first hoverfx requires a worker path",
1285                ));
1286            }
1287        }
1288        for effect in &self.registry.effects {
1289            validate_effect(effect, &mut report);
1290        }
1291
1292        report
1293    }
1294}
1295
1296fn validate_performance(
1297    performance: &HoverFxPerformanceConfig,
1298    report: &mut HoverFxValidationReport,
1299) {
1300    if !performance.dpr_cap.is_finite()
1301        || !(MIN_HOVERFX_PERF_DPR_CAP..=MAX_HOVERFX_PERF_DPR_CAP).contains(&performance.dpr_cap)
1302    {
1303        push_numeric_issue(
1304            HoverFxValidationCode::InvalidPerformanceMetric,
1305            None,
1306            "performance.dpr_cap",
1307            format!(
1308                "performance dpr cap must be finite and between {} and {}",
1309                MIN_HOVERFX_PERF_DPR_CAP, MAX_HOVERFX_PERF_DPR_CAP
1310            ),
1311            report,
1312        );
1313    }
1314    if performance.idle_release_timeout_ms > MAX_HOVERFX_PERF_IDLE_RELEASE_TIMEOUT_MS {
1315        push_numeric_issue(
1316            HoverFxValidationCode::InvalidPerformanceMetric,
1317            None,
1318            "performance.idle_release_timeout_ms",
1319            format!(
1320                "idle release timeout must be no more than {}ms",
1321                MAX_HOVERFX_PERF_IDLE_RELEASE_TIMEOUT_MS
1322            ),
1323            report,
1324        );
1325    }
1326    if let Some(margin) = performance.candidate_observer_margin_px {
1327        if margin > MAX_HOVERFX_PERF_CANDIDATE_OBSERVER_MARGIN_PX {
1328            push_numeric_issue(
1329                HoverFxValidationCode::InvalidPerformanceMetric,
1330                None,
1331                "performance.candidate_observer_margin_px",
1332                format!(
1333                    "candidate observer margin must be no more than {}px",
1334                    MAX_HOVERFX_PERF_CANDIDATE_OBSERVER_MARGIN_PX
1335                ),
1336                report,
1337            );
1338        }
1339    }
1340}
1341
1342fn validate_effect(effect: &HoverFxDefinition, report: &mut HoverFxValidationReport) {
1343    if !is_hoverfx_id(&effect.id) {
1344        report.push(HoverFxValidationIssue::effect_error(
1345            HoverFxValidationCode::InvalidEffectId,
1346            effect.id.clone(),
1347            "id",
1348            "effect id must be a kebab-case hoverfx id",
1349        ));
1350    }
1351    if let Some(radius_px) = effect.radius_px {
1352        validate_radius(radius_px, "radius_px", Some(&effect.id), report);
1353    }
1354    if let Some(strength) = effect.strength {
1355        validate_strength(strength, "strength", Some(&effect.id), report);
1356    }
1357    if let Some(smoothing) = effect.smoothing {
1358        validate_smoothing(smoothing, "smoothing", Some(&effect.id), report);
1359    }
1360    if let Some(custom_shape) = &effect.custom_shape {
1361        if !is_safe_css_custom_value(custom_shape) {
1362            report.push(HoverFxValidationIssue::effect_error(
1363                HoverFxValidationCode::UnsafeCustomShapeValue,
1364                effect.id.clone(),
1365                "custom_shape",
1366                "custom shape must be a safe CSS value",
1367            ));
1368        }
1369    }
1370    if let Some(text_reveal) = &effect.text_reveal {
1371        validate_text_reveal(text_reveal, &effect.id, report);
1372    }
1373    if let Some(sand) = &effect.sand {
1374        validate_sand(sand, &effect.id, report);
1375    }
1376    for (name, value) in &effect.css_vars {
1377        if !is_custom_property_name(name) {
1378            report.push(HoverFxValidationIssue::effect_error(
1379                HoverFxValidationCode::InvalidCssVariableName,
1380                effect.id.clone(),
1381                name.clone(),
1382                "hoverfx CSS variables must be CSS custom properties",
1383            ));
1384        }
1385        if !is_safe_css_custom_value(value) {
1386            report.push(HoverFxValidationIssue::effect_error(
1387                HoverFxValidationCode::UnsafeCssValue,
1388                effect.id.clone(),
1389                name.clone(),
1390                "hoverfx CSS variable values must not contain declarations, URLs, or scriptable protocols",
1391            ));
1392        }
1393    }
1394}
1395
1396fn validate_sand(sand: &HoverFxSandConfig, effect: &str, report: &mut HoverFxValidationReport) {
1397    for (field, value, min, max) in [
1398        ("sand.grain_size_px", sand.grain_size_px, 0.2, 12.0),
1399        ("sand.grain_density", sand.grain_density, 0.05, 8.0),
1400        ("sand.shimmer_density", sand.shimmer_density, 0.0, 2.0),
1401        ("sand.shimmer_strength", sand.shimmer_strength, 0.0, 4.0),
1402        (
1403            "sand.shimmer_radius_px",
1404            sand.shimmer_radius_px,
1405            1.0,
1406            2_000.0,
1407        ),
1408        ("sand.specular_strength", sand.specular_strength, 0.0, 4.0),
1409        ("sand.roughness", sand.roughness, 0.0, 1.0),
1410    ] {
1411        if !value.is_finite() || value < min || value > max {
1412            report.push(HoverFxValidationIssue::effect_error(
1413                HoverFxValidationCode::InvalidSandMetric,
1414                effect,
1415                field,
1416                format!("sand metric must be finite and between {min} and {max}"),
1417            ));
1418        }
1419    }
1420    if !(MIN_HOVERFX_SAND_ANIMATION_SPEED_MS..=MAX_HOVERFX_SAND_ANIMATION_SPEED_MS)
1421        .contains(&sand.animation_speed_ms)
1422    {
1423        report.push(HoverFxValidationIssue::effect_error(
1424            HoverFxValidationCode::InvalidSandAnimationSpeed,
1425            effect,
1426            "sand.animation_speed_ms",
1427            format!(
1428                "sand animation speed must be between {}ms and {}ms",
1429                MIN_HOVERFX_SAND_ANIMATION_SPEED_MS, MAX_HOVERFX_SAND_ANIMATION_SPEED_MS
1430            ),
1431        ));
1432    }
1433    for (field, value) in [
1434        ("sand.color", sand.color.as_str()),
1435        ("sand.highlight_color", sand.highlight_color.as_str()),
1436    ] {
1437        if !is_safe_css_custom_value(value) {
1438            report.push(HoverFxValidationIssue::effect_error(
1439                HoverFxValidationCode::UnsafeSandCssValue,
1440                effect,
1441                field,
1442                "sand CSS values must not contain declarations, URLs, or scriptable protocols",
1443            ));
1444        }
1445    }
1446}
1447
1448fn validate_text_reveal(
1449    text_reveal: &HoverFxTextRevealConfig,
1450    effect: &str,
1451    report: &mut HoverFxValidationReport,
1452) {
1453    let charset_len = text_reveal.charset.chars().count();
1454    if charset_len == 0
1455        || charset_len > MAX_HOVERFX_TEXT_REVEAL_CHARSET_CHARS
1456        || text_reveal.charset.chars().any(char::is_control)
1457    {
1458        report.push(HoverFxValidationIssue::effect_error(
1459            HoverFxValidationCode::InvalidTextRevealCharset,
1460            effect,
1461            "text_reveal.charset",
1462            format!(
1463                "text reveal charset must contain 1 to {} printable characters",
1464                MAX_HOVERFX_TEXT_REVEAL_CHARSET_CHARS
1465            ),
1466        ));
1467    }
1468    if !(MIN_HOVERFX_TEXT_REVEAL_CYCLE_SPEED_MS..=MAX_HOVERFX_TEXT_REVEAL_CYCLE_SPEED_MS)
1469        .contains(&text_reveal.cycle_speed_ms)
1470    {
1471        report.push(HoverFxValidationIssue::effect_error(
1472            HoverFxValidationCode::InvalidTextRevealCycleSpeed,
1473            effect,
1474            "text_reveal.cycle_speed_ms",
1475            format!(
1476                "text reveal cycle speed must be between {}ms and {}ms",
1477                MIN_HOVERFX_TEXT_REVEAL_CYCLE_SPEED_MS, MAX_HOVERFX_TEXT_REVEAL_CYCLE_SPEED_MS
1478            ),
1479        ));
1480    }
1481    if !text_reveal.density.is_finite() || !(0.05..=8.0).contains(&text_reveal.density) {
1482        report.push(HoverFxValidationIssue::effect_error(
1483            HoverFxValidationCode::InvalidTextRevealMetric,
1484            effect,
1485            "text_reveal.density",
1486            "text reveal density must be finite and between 0.05 and 8.0",
1487        ));
1488    }
1489    if text_reveal.font_size_px == 0
1490        || text_reveal.font_size_px > MAX_HOVERFX_TEXT_REVEAL_FONT_SIZE_PX
1491        || text_reveal.gap_px > MAX_HOVERFX_TEXT_REVEAL_GAP_PX
1492    {
1493        report.push(HoverFxValidationIssue::effect_error(
1494            HoverFxValidationCode::InvalidTextRevealMetric,
1495            effect,
1496            "text_reveal.font_size_px",
1497            "text reveal font size and gap must stay within supported pixel ranges",
1498        ));
1499    }
1500    for (field, value) in [
1501        ("text_reveal.font_family", text_reveal.font_family.as_str()),
1502        ("text_reveal.color", text_reveal.color.as_str()),
1503    ] {
1504        if !is_safe_css_custom_value(value) {
1505            report.push(HoverFxValidationIssue::effect_error(
1506                HoverFxValidationCode::UnsafeTextRevealCssValue,
1507                effect,
1508                field,
1509                "text reveal CSS values must not contain declarations, URLs, or scriptable protocols",
1510            ));
1511        }
1512    }
1513    if !is_supported_textfx_effect(&text_reveal.textfx_effect) {
1514        report.push(HoverFxValidationIssue::effect_error(
1515            HoverFxValidationCode::UnsupportedTextFxEffect,
1516            effect,
1517            "text_reveal.textfx_effect",
1518            format!(
1519                "textfx effect must be one of: {}",
1520                SUPPORTED_HOVERFX_TEXTFX_EFFECTS.join(", ")
1521            ),
1522        ));
1523    }
1524}
1525
1526fn validate_radius(
1527    radius_px: u16,
1528    field: &str,
1529    effect: Option<&str>,
1530    report: &mut HoverFxValidationReport,
1531) {
1532    if !(MIN_HOVERFX_RADIUS_PX..=MAX_HOVERFX_RADIUS_PX).contains(&radius_px) {
1533        push_numeric_issue(
1534            HoverFxValidationCode::InvalidRadius,
1535            effect,
1536            field,
1537            format!(
1538                "radius must be between {}px and {}px",
1539                MIN_HOVERFX_RADIUS_PX, MAX_HOVERFX_RADIUS_PX
1540            ),
1541            report,
1542        );
1543    }
1544}
1545
1546fn validate_strength(
1547    strength: f32,
1548    field: &str,
1549    effect: Option<&str>,
1550    report: &mut HoverFxValidationReport,
1551) {
1552    if !strength.is_finite() || !(0.0..=MAX_HOVERFX_STRENGTH).contains(&strength) {
1553        push_numeric_issue(
1554            HoverFxValidationCode::InvalidStrength,
1555            effect,
1556            field,
1557            format!("strength must be finite and between 0.0 and {MAX_HOVERFX_STRENGTH}"),
1558            report,
1559        );
1560    }
1561}
1562
1563fn validate_smoothing(
1564    smoothing: f32,
1565    field: &str,
1566    effect: Option<&str>,
1567    report: &mut HoverFxValidationReport,
1568) {
1569    if !smoothing.is_finite() || !(0.0..=1.0).contains(&smoothing) {
1570        push_numeric_issue(
1571            HoverFxValidationCode::InvalidSmoothing,
1572            effect,
1573            field,
1574            "smoothing must be finite and between 0.0 and 1.0",
1575            report,
1576        );
1577    }
1578}
1579
1580fn push_numeric_issue(
1581    code: HoverFxValidationCode,
1582    effect: Option<&str>,
1583    field: &str,
1584    message: impl Into<String>,
1585    report: &mut HoverFxValidationReport,
1586) {
1587    let message = message.into();
1588    match effect {
1589        Some(effect) => report.push(HoverFxValidationIssue::effect_error(
1590            code,
1591            effect.to_string(),
1592            field,
1593            message,
1594        )),
1595        None => report.push(HoverFxValidationIssue::error(code, field, message)),
1596    }
1597}
1598
1599pub fn hoverfx_id(id: impl AsRef<str>) -> String {
1600    let mut normalized = String::with_capacity(id.as_ref().len());
1601    let mut previous_dash = false;
1602    for ch in id.as_ref().trim().chars() {
1603        if ch.is_ascii_alphanumeric() {
1604            normalized.push(ch.to_ascii_lowercase());
1605            previous_dash = false;
1606        } else if matches!(ch, '-' | '_' | ' ' | '.' | ':' | '/') && !previous_dash {
1607            normalized.push('-');
1608            previous_dash = true;
1609        }
1610    }
1611    while normalized.ends_with('-') {
1612        normalized.pop();
1613    }
1614    if normalized.is_empty() {
1615        "effect".to_string()
1616    } else {
1617        normalized
1618    }
1619}
1620
1621pub fn is_hoverfx_id(id: &str) -> bool {
1622    let bytes = id.as_bytes();
1623    if bytes.is_empty() || bytes.first() == Some(&b'-') || bytes.last() == Some(&b'-') {
1624        return false;
1625    }
1626    let mut previous_dash = false;
1627    for byte in bytes {
1628        let valid = byte.is_ascii_lowercase() || byte.is_ascii_digit() || *byte == b'-';
1629        if !valid {
1630            return false;
1631        }
1632        if *byte == b'-' {
1633            if previous_dash {
1634                return false;
1635            }
1636            previous_dash = true;
1637        } else {
1638            previous_dash = false;
1639        }
1640    }
1641    true
1642}
1643
1644pub fn is_custom_property_name(name: &str) -> bool {
1645    let Some(rest) = name.strip_prefix("--") else {
1646        return false;
1647    };
1648    !rest.is_empty()
1649        && rest
1650            .bytes()
1651            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))
1652}
1653
1654pub fn is_safe_css_custom_value(value: &str) -> bool {
1655    let value = value.trim();
1656    if value.is_empty() || value.len() > 512 {
1657        return false;
1658    }
1659    if value
1660        .chars()
1661        .any(|ch| ch.is_control() || matches!(ch, '{' | '}' | ';' | '<' | '>'))
1662    {
1663        return false;
1664    }
1665    let lower = value.to_ascii_lowercase();
1666    ![
1667        "url(",
1668        "expression(",
1669        "@import",
1670        "javascript:",
1671        "vbscript:",
1672        "data:",
1673        "</script",
1674    ]
1675    .iter()
1676    .any(|needle| lower.contains(needle))
1677}
1678
1679pub fn is_supported_textfx_effect(effect: &str) -> bool {
1680    let effect = hoverfx_id(effect);
1681    SUPPORTED_HOVERFX_TEXTFX_EFFECTS
1682        .iter()
1683        .any(|supported| *supported == effect)
1684}
1685
1686#[cfg(test)]
1687mod tests {
1688    use super::*;
1689    use serde_json::json;
1690
1691    #[test]
1692    fn defaults_are_worker_first_and_include_builtin_presets() {
1693        let config = HoverFxConfig::default();
1694        assert_eq!(config.renderer, HoverFxRenderer::WorkerFirst);
1695        assert_eq!(config.radius_px, DEFAULT_HOVERFX_RADIUS_PX);
1696        assert_eq!(config.falloff, HoverFxFalloff::Smooth);
1697        assert_eq!(config.strength, DEFAULT_HOVERFX_STRENGTH);
1698        assert_eq!(config.smoothing, DEFAULT_HOVERFX_SMOOTHING);
1699        assert!(config.performance.lazy_local_layers);
1700        assert!(config.performance.worker_local_layers);
1701        assert!(config.performance.dirty_rect_rendering);
1702        assert!(config.performance.shader_texture_cache);
1703        assert_eq!(config.performance.dpr_cap, DEFAULT_HOVERFX_PERF_DPR_CAP);
1704        assert_eq!(
1705            config.performance.idle_release_timeout_ms,
1706            DEFAULT_HOVERFX_PERF_IDLE_RELEASE_TIMEOUT_MS
1707        );
1708        assert_eq!(config.performance.candidate_observer_margin_px, None);
1709        assert_eq!(
1710            config.max_active_elements,
1711            DEFAULT_HOVERFX_MAX_ACTIVE_ELEMENTS
1712        );
1713        for preset in HoverFxPreset::all() {
1714            assert!(config.registry.contains_effect(preset.as_attr()));
1715        }
1716        assert!(config.validate().is_valid());
1717    }
1718
1719    #[test]
1720    fn preset_helpers_and_kebab_case_serialization_match_attrs() {
1721        assert_eq!(HoverFxPreset::SoftGlow.as_attr(), "soft-glow");
1722        assert_eq!(HoverFxPreset::BorderTrace.label(), "Border trace");
1723        assert_eq!(
1724            serde_json::to_value(HoverFxPreset::ColorWash).unwrap(),
1725            json!("color-wash")
1726        );
1727        assert_eq!(
1728            serde_json::to_value(HoverFxShape::RoundedRect).unwrap(),
1729            json!("rounded-rect")
1730        );
1731        assert_eq!(
1732            serde_json::to_value(HoverFxRenderer::WorkerFirst).unwrap(),
1733            json!("worker-first")
1734        );
1735        assert_eq!(HoverFxPreset::BinaryReveal.as_attr(), "binary-reveal");
1736        assert_eq!(HoverFxPreset::BinaryReveal.label(), "Binary reveal");
1737        assert_eq!(HoverFxPreset::TextureReveal.as_attr(), "texture-reveal");
1738        assert_eq!(HoverFxPreset::TextureReveal.label(), "Texture reveal");
1739        assert_eq!(HoverFxPreset::Sand.as_attr(), "sand");
1740        assert_eq!(HoverFxPreset::Sand.label(), "Sand");
1741        assert_eq!(
1742            serde_json::to_value(HoverFxPreset::Sand).unwrap(),
1743            json!("sand")
1744        );
1745        assert_eq!(HoverFxTextContrastMode::Auto.as_attr(), "auto");
1746        assert_eq!(HoverFxTextContrastMode::Darken.as_attr(), "darken");
1747        assert_eq!(HoverFxTextContrastMode::Invert.as_attr(), "invert");
1748        assert_eq!(
1749            serde_json::to_value(HoverFxTextContrastMode::Invert).unwrap(),
1750            json!("invert")
1751        );
1752        assert_eq!(
1753            serde_json::to_value(HoverFxTextureRevealMode::StaticGrain).unwrap(),
1754            json!("static-grain")
1755        );
1756        assert_eq!(HoverFxPreset::all().len(), 8);
1757    }
1758
1759    #[test]
1760    fn builder_overrides_serialize_camel_case() {
1761        let config = HoverFxConfig::new()
1762            .with_default_effect("brand-wash")
1763            .with_radius_px(260)
1764            .with_shape(HoverFxShape::Square)
1765            .with_falloff(HoverFxFalloff::Exponential)
1766            .with_strength(1.4)
1767            .with_smoothing(0.25)
1768            .with_max_active_elements(12)
1769            .with_renderer(HoverFxRenderer::CssOnly)
1770            .with_performance(
1771                HoverFxPerformanceConfig::default()
1772                    .with_lazy_local_layers(false)
1773                    .with_worker_local_layers(false)
1774                    .with_dirty_rect_rendering(false)
1775                    .with_shader_texture_cache(false)
1776                    .with_dpr_cap(1.5)
1777                    .with_idle_release_timeout_ms(2400)
1778                    .with_candidate_observer_margin_px(720),
1779            )
1780            .with_runtime_path("/assets/custom-hoverfx.js")
1781            .with_worker_path("/assets/custom-hoverfx-worker.js")
1782            .with_effect(
1783                HoverFxDefinition::new("brand-wash", "Brand wash")
1784                    .with_preset(HoverFxPreset::ColorWash)
1785                    .with_radius_px(300)
1786                    .with_text_contrast(HoverFxTextContrastMode::Auto)
1787                    .with_css_var("--dxh-color", "rgba(14,165,233,0.30)"),
1788            );
1789
1790        assert!(config.validate().is_valid());
1791        let json = serde_json::to_value(&config).unwrap();
1792        assert_eq!(json["defaultEffect"], "brand-wash");
1793        assert_eq!(json["radiusPx"], 260);
1794        assert_eq!(json["maxActiveElements"], 12);
1795        assert_eq!(json["renderer"], "css-only");
1796        assert_eq!(json["performance"]["lazyLocalLayers"], false);
1797        assert_eq!(json["performance"]["workerLocalLayers"], false);
1798        assert_eq!(json["performance"]["dirtyRectRendering"], false);
1799        assert_eq!(json["performance"]["shaderTextureCache"], false);
1800        assert_eq!(json["performance"]["dprCap"], 1.5);
1801        assert_eq!(json["performance"]["idleReleaseTimeoutMs"], 2400);
1802        assert_eq!(json["performance"]["candidateObserverMarginPx"], 720);
1803        assert_eq!(json["registry"]["effects"][8]["preset"], "color-wash");
1804        assert_eq!(json["registry"]["effects"][8]["textContrast"], "auto");
1805    }
1806
1807    #[test]
1808    fn validation_reports_invalid_config_and_effect_values() {
1809        let mut config = HoverFxConfig::new()
1810            .with_default_effect("missing")
1811            .with_radius_px(0)
1812            .with_strength(f32::INFINITY)
1813            .with_smoothing(1.4)
1814            .with_max_active_elements(0)
1815            .with_dpr_cap(4.0)
1816            .with_runtime_path("")
1817            .with_worker_path("");
1818        config.registry.effects.push(HoverFxDefinition {
1819            id: "Bad Id".to_string(),
1820            label: "Bad".to_string(),
1821            preset: None,
1822            radius_px: Some(0),
1823            shape: Some(HoverFxShape::Polygon),
1824            falloff: Some(HoverFxFalloff::Hard),
1825            strength: Some(-1.0),
1826            smoothing: Some(f32::NAN),
1827            custom_shape: Some("polygon(0 0, url(javascript:bad))".to_string()),
1828            text_reveal: Some(
1829                HoverFxTextRevealConfig::default()
1830                    .with_charset("")
1831                    .with_cycle_speed_ms(1)
1832                    .with_density(f32::NAN)
1833                    .with_font_size_px(0)
1834                    .with_color("url(javascript:bad)")
1835                    .with_textfx_effect("fade"),
1836            ),
1837            texture_reveal: None,
1838            sand: Some(
1839                HoverFxSandConfig::default()
1840                    .with_grain_size_px(0.0)
1841                    .with_shimmer_density(f32::NAN)
1842                    .with_shimmer_radius_px(0.0)
1843                    .with_animation_speed_ms(1)
1844                    .with_color("url(javascript:bad)"),
1845            ),
1846            text_contrast: Some(HoverFxTextContrastMode::Invert),
1847            css_vars: BTreeMap::from([
1848                ("color".to_string(), "red".to_string()),
1849                (
1850                    "--dxh-bg".to_string(),
1851                    "url(javascript:alert(1))".to_string(),
1852                ),
1853            ]),
1854        });
1855
1856        let report = config.validate();
1857        let codes: Vec<_> = report.errors().map(|issue| issue.code).collect();
1858        assert!(codes.contains(&HoverFxValidationCode::MissingDefaultEffect));
1859        assert!(codes.contains(&HoverFxValidationCode::InvalidEffectId));
1860        assert!(codes.contains(&HoverFxValidationCode::EmptyRuntimePath));
1861        assert!(codes.contains(&HoverFxValidationCode::EmptyWorkerPath));
1862        assert!(codes.contains(&HoverFxValidationCode::InvalidRadius));
1863        assert!(codes.contains(&HoverFxValidationCode::InvalidStrength));
1864        assert!(codes.contains(&HoverFxValidationCode::InvalidSmoothing));
1865        assert!(codes.contains(&HoverFxValidationCode::InvalidMaxActiveElements));
1866        assert!(codes.contains(&HoverFxValidationCode::InvalidCssVariableName));
1867        assert!(codes.contains(&HoverFxValidationCode::UnsafeCssValue));
1868        assert!(codes.contains(&HoverFxValidationCode::UnsafeCustomShapeValue));
1869        assert!(codes.contains(&HoverFxValidationCode::InvalidTextRevealCharset));
1870        assert!(codes.contains(&HoverFxValidationCode::InvalidTextRevealCycleSpeed));
1871        assert!(codes.contains(&HoverFxValidationCode::InvalidTextRevealMetric));
1872        assert!(codes.contains(&HoverFxValidationCode::UnsafeTextRevealCssValue));
1873        assert!(codes.contains(&HoverFxValidationCode::UnsupportedTextFxEffect));
1874        assert!(codes.contains(&HoverFxValidationCode::InvalidSandMetric));
1875        assert!(codes.contains(&HoverFxValidationCode::InvalidSandAnimationSpeed));
1876        assert!(codes.contains(&HoverFxValidationCode::UnsafeSandCssValue));
1877        assert!(codes.contains(&HoverFxValidationCode::InvalidPerformanceMetric));
1878    }
1879
1880    #[test]
1881    fn binary_reveal_defaults_and_textfx_bridge_serialize() {
1882        let definition = HoverFxDefinition::from_preset(HoverFxPreset::BinaryReveal);
1883        let text_reveal = definition.text_reveal.expect("binary reveal config");
1884        assert_eq!(definition.radius_px, Some(300));
1885        assert_eq!(definition.shape, Some(HoverFxShape::Circle));
1886        assert_eq!(definition.falloff, Some(HoverFxFalloff::Smooth));
1887        assert_eq!(definition.strength, Some(1.15));
1888        assert_eq!(text_reveal.charset, "01");
1889        assert_eq!(text_reveal.cycle_speed_ms, 220);
1890        assert_eq!(
1891            text_reveal.animation_source,
1892            HoverFxTextAnimationSource::Auto
1893        );
1894        assert_eq!(text_reveal.renderer, HoverFxTextRevealRenderer::GlyphAtlas);
1895        assert_eq!(text_reveal.textfx_effect, "scramble");
1896        assert_eq!(
1897            HoverFxTextRevealRenderer::GlyphAtlas.as_attr(),
1898            "glyph-atlas"
1899        );
1900        assert_eq!(
1901            HoverFxTextRevealRenderer::CanvasGrid.as_attr(),
1902            "canvas-grid"
1903        );
1904        assert!(is_supported_textfx_effect("gradient-shift"));
1905        assert!(!is_supported_textfx_effect("fade"));
1906
1907        let json = text_reveal.to_json().unwrap();
1908        assert!(json.contains(r#""animationSource":"auto""#));
1909        assert!(json.contains(r#""renderer":"glyph-atlas""#));
1910        assert!(
1911            HoverFxTextRevealConfig::default()
1912                .with_renderer(HoverFxTextRevealRenderer::CanvasGrid)
1913                .to_json()
1914                .unwrap()
1915                .contains(r#""renderer":"canvas-grid""#)
1916        );
1917        let textfx = text_reveal
1918            .to_textfx_config_json("binary-demo", "0101")
1919            .unwrap();
1920        assert!(textfx.contains(r#""effect":"scramble""#));
1921        assert!(textfx.contains(r#""charset":"01""#));
1922        assert!(textfx.contains(r#""speedMs":220"#));
1923    }
1924
1925    #[test]
1926    fn texture_reveal_defaults_and_builder_serialize() {
1927        let definition = HoverFxDefinition::from_preset(HoverFxPreset::TextureReveal);
1928        let texture_reveal = definition.texture_reveal.expect("texture reveal config");
1929        assert_eq!(definition.radius_px, Some(340));
1930        assert_eq!(definition.shape, Some(HoverFxShape::Circle));
1931        assert_eq!(definition.falloff, Some(HoverFxFalloff::Smooth));
1932        assert_eq!(definition.strength, Some(1.1));
1933        assert_eq!(texture_reveal.mode, HoverFxTextureRevealMode::Auto);
1934        assert_eq!(HoverFxTextureRevealMode::Halftone.as_attr(), "halftone");
1935        assert_eq!(texture_reveal.to_json().unwrap(), r#"{"mode":"auto"}"#);
1936        assert!(
1937            HoverFxTextureRevealConfig::default()
1938                .with_mode(HoverFxTextureRevealMode::StaticGrain)
1939                .to_json()
1940                .unwrap()
1941                .contains(r#""mode":"static-grain""#)
1942        );
1943    }
1944
1945    #[test]
1946    fn sand_defaults_and_builder_serialize() {
1947        let definition = HoverFxDefinition::from_preset(HoverFxPreset::Sand);
1948        let sand = definition.sand.expect("sand config");
1949        assert_eq!(definition.radius_px, Some(320));
1950        assert_eq!(definition.shape, Some(HoverFxShape::Circle));
1951        assert_eq!(definition.falloff, Some(HoverFxFalloff::Smooth));
1952        assert_eq!(definition.strength, Some(1.1));
1953        assert_eq!(sand.grain_size_px, DEFAULT_HOVERFX_SAND_GRAIN_SIZE_PX);
1954        assert_eq!(
1955            sand.animation_speed_ms,
1956            DEFAULT_HOVERFX_SAND_ANIMATION_SPEED_MS
1957        );
1958        assert_eq!(
1959            sand.shimmer_radius_px,
1960            DEFAULT_HOVERFX_SAND_SHIMMER_RADIUS_PX
1961        );
1962        assert_eq!(sand.color_source, HoverFxSandColorSource::Custom);
1963        assert!(definition.css_vars.contains_key("--dxh-sand-color"));
1964
1965        let custom = HoverFxSandConfig::default()
1966            .with_grain_size_px(1.6)
1967            .with_grain_density(1.4)
1968            .with_shimmer_density(0.22)
1969            .with_shimmer_strength(0.9)
1970            .with_shimmer_radius_px(280.0)
1971            .with_specular_strength(1.2)
1972            .with_roughness(0.36)
1973            .with_animation_speed_ms(720)
1974            .with_color_source(HoverFxSandColorSource::Element)
1975            .with_color("#c9a96a")
1976            .with_highlight_color("#fff2b8");
1977        let json = serde_json::to_value(&custom).unwrap();
1978        assert!((json["grainSizePx"].as_f64().unwrap() - 1.6).abs() < 0.001);
1979        assert_eq!(json["shimmerRadiusPx"], 280.0);
1980        assert_eq!(json["animationSpeedMs"], 720);
1981        assert_eq!(json["colorSource"], "element");
1982        assert_eq!(json["highlightColor"], "#fff2b8");
1983        assert!(
1984            HoverFxDefinition::new("custom-sand", "Custom sand")
1985                .with_preset(HoverFxPreset::Sand)
1986                .with_sand(custom)
1987                .with_radius_px(360)
1988                .with_strength(1.2)
1989                .with_shape(HoverFxShape::Circle)
1990                .with_falloff(HoverFxFalloff::Smooth)
1991                .preset
1992                .is_some()
1993        );
1994    }
1995
1996    #[test]
1997    fn hoverfx_id_sanitizes_to_kebab_case() {
1998        assert_eq!(hoverfx_id(" Soft Glow "), "soft-glow");
1999        assert_eq!(hoverfx_id("brand.fx/accent"), "brand-fx-accent");
2000        assert_eq!(hoverfx_id(""), "effect");
2001        assert!(is_hoverfx_id("border-trace"));
2002        assert!(!is_hoverfx_id("BorderTrace"));
2003        assert!(!is_hoverfx_id("-bad"));
2004        assert!(!is_hoverfx_id("bad--id"));
2005    }
2006
2007    #[test]
2008    fn css_safety_allows_values_but_rejects_declarations_and_urls() {
2009        assert!(is_custom_property_name("--dxh-color"));
2010        assert!(!is_custom_property_name("dxh-color"));
2011        assert!(is_safe_css_custom_value("rgba(14,165,233,0.30)"));
2012        assert!(is_safe_css_custom_value(
2013            "polygon(0 0, 100% 0, 80% 100%, 0 90%)"
2014        ));
2015        assert!(!is_safe_css_custom_value("red; background: blue"));
2016        assert!(!is_safe_css_custom_value("url(https://example.com/a.png)"));
2017        assert!(!is_safe_css_custom_value("javascript:alert(1)"));
2018        assert!(!is_safe_css_custom_value("<script>alert(1)</script>"));
2019    }
2020}