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}