Skip to main content

dioxus_hoverfx_core/
lib.rs

1use std::collections::BTreeMap;
2use std::fmt;
3
4use serde::{Deserialize, Serialize};
5
6mod integration;
7pub use integration::*;
8
9pub const DEFAULT_HOVERFX_RUNTIME_BASE_PATH: &str = "/assets/dioxus-hoverfx.js";
10pub const DEFAULT_HOVERFX_WORKER_BASE_PATH: &str = "/assets/dioxus-hoverfx-worker.js";
11pub const DEFAULT_HOVERFX_RUNTIME_VERSION: &str = "1";
12pub const DEFAULT_HOVERFX_WORKER_VERSION: &str = "1";
13pub const DEFAULT_HOVERFX_RUNTIME_PATH: &str = "/assets/dioxus-hoverfx.js?v=1";
14pub const DEFAULT_HOVERFX_WORKER_PATH: &str = "/assets/dioxus-hoverfx-worker.js?v=1";
15pub const HOVERFX_PACKAGE_NAME: &str = "dioxus-hoverfx";
16pub const HOVERFX_PACKAGE_VERSION: &str = env!("CARGO_PKG_VERSION");
17pub const DEFAULT_HOVERFX_RADIUS_PX: u16 = 180;
18pub const DEFAULT_HOVERFX_RANGE_PX: u16 = DEFAULT_HOVERFX_RADIUS_PX;
19pub const DEFAULT_HOVERFX_STRENGTH: f32 = 1.0;
20pub const DEFAULT_HOVERFX_SMOOTHING: f32 = 0.18;
21pub const DEFAULT_HOVERFX_MAX_ACTIVE_ELEMENTS: u16 = 8;
22pub const DEFAULT_HOVERFX_PERF_LAZY_LOCAL_LAYERS: bool = true;
23pub const DEFAULT_HOVERFX_PERF_WORKER_LOCAL_LAYERS: bool = true;
24pub const DEFAULT_HOVERFX_PERF_DIRTY_RECT_RENDERING: bool = true;
25pub const DEFAULT_HOVERFX_PERF_SHADER_TEXTURE_CACHE: bool = true;
26pub const DEFAULT_HOVERFX_PERF_DPR_CAP: f32 = 2.0;
27pub const DEFAULT_HOVERFX_PERF_IDLE_RELEASE_TIMEOUT_MS: u16 = 1_200;
28pub const MIN_HOVERFX_RADIUS_PX: u16 = 1;
29pub const MAX_HOVERFX_RADIUS_PX: u16 = 2_000;
30pub const MIN_HOVERFX_RANGE_PX: u16 = 0;
31pub const MAX_HOVERFX_RANGE_PX: u16 = 5_000;
32pub const MAX_HOVERFX_STRENGTH: f32 = 10.0;
33pub const MAX_HOVERFX_MAX_ACTIVE_ELEMENTS: u16 = 64;
34pub const MIN_HOVERFX_PERF_DPR_CAP: f32 = 1.0;
35pub const MAX_HOVERFX_PERF_DPR_CAP: f32 = 3.0;
36pub const MAX_HOVERFX_PERF_IDLE_RELEASE_TIMEOUT_MS: u16 = 60_000;
37pub const MAX_HOVERFX_PERF_CANDIDATE_OBSERVER_MARGIN_PX: u16 = 5_000;
38pub const DEFAULT_HOVERFX_TEXT_REVEAL_CHARSET: &str = "01";
39pub const DEFAULT_HOVERFX_TEXT_REVEAL_CYCLE_SPEED_MS: u16 = 220;
40pub const DEFAULT_HOVERFX_TEXT_REVEAL_DENSITY: f32 = 1.0;
41pub const DEFAULT_HOVERFX_TEXT_REVEAL_FONT_SIZE_PX: u16 = 14;
42pub const DEFAULT_HOVERFX_TEXT_REVEAL_GAP_PX: u16 = 6;
43pub const DEFAULT_HOVERFX_TEXT_REVEAL_FONT_FAMILY: &str =
44    "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace";
45pub const DEFAULT_HOVERFX_TEXT_REVEAL_COLOR: &str =
46    "var(--dxh-binary-color, var(--dxt-accent, #22d3ee))";
47pub const DEFAULT_HOVERFX_TEXT_REVEAL_TEXTFX_EFFECT: &str = "scramble";
48pub const DEFAULT_HOVERFX_TOOLTIP_OFFSET_PX: u16 = 12;
49pub const DEFAULT_HOVERFX_TOOLTIP_MAX_WIDTH_PX: u16 = 280;
50pub const DEFAULT_HOVERFX_TOOLTIP_SHOW_DELAY_MS: u16 = 0;
51pub const DEFAULT_HOVERFX_TOOLTIP_HIDE_DELAY_MS: u16 = 90;
52pub const DEFAULT_HOVERFX_TOOLTIP_DURATION_MS: u16 = 260;
53pub const DEFAULT_HOVERFX_TOOLTIP_SPEED_MS: u16 = 18;
54pub const DEFAULT_HOVERFX_TOOLTIP_STAGGER_MS: u16 = 8;
55pub const DEFAULT_HOVERFX_TOOLTIP_TEXTFX_EFFECT: &str = "scramble";
56pub const DEFAULT_HOVERFX_TOOLTIP_SPLIT: &str = "chars";
57pub const DEFAULT_HOVERFX_TOOLTIP_BOX_OPACITY: f32 = 0.88;
58pub const MIN_HOVERFX_TOOLTIP_MAX_WIDTH_PX: u16 = 80;
59pub const MAX_HOVERFX_TOOLTIP_MAX_WIDTH_PX: u16 = 720;
60pub const MAX_HOVERFX_TOOLTIP_OFFSET_PX: u16 = 96;
61pub const MAX_HOVERFX_TOOLTIP_DELAY_MS: u16 = 2_000;
62pub const MAX_HOVERFX_TOOLTIP_DURATION_MS: u16 = 4_000;
63pub const MAX_HOVERFX_TOOLTIP_TEXT_CHARS: usize = 2_048;
64pub const MIN_HOVERFX_TOOLTIP_BOX_OPACITY: f32 = 0.0;
65pub const MAX_HOVERFX_TOOLTIP_BOX_OPACITY: f32 = 1.0;
66pub const DEFAULT_HOVERFX_SAND_GRAIN_SIZE_PX: f32 = 1.15;
67pub const DEFAULT_HOVERFX_SAND_GRAIN_DENSITY: f32 = 1.0;
68pub const DEFAULT_HOVERFX_SAND_SHIMMER_DENSITY: f32 = 0.16;
69pub const DEFAULT_HOVERFX_SAND_SHIMMER_STRENGTH: f32 = 0.75;
70pub const DEFAULT_HOVERFX_SAND_SHIMMER_RADIUS_PX: f32 = 250.0;
71pub const DEFAULT_HOVERFX_SAND_SPECULAR_STRENGTH: f32 = 0.85;
72pub const DEFAULT_HOVERFX_SAND_ROUGHNESS: f32 = 0.42;
73pub const DEFAULT_HOVERFX_SAND_ANIMATION_SPEED_MS: u16 = 900;
74pub const DEFAULT_HOVERFX_SAND_COLOR: &str = "var(--dxh-sand-color, #d7b878)";
75pub const DEFAULT_HOVERFX_SAND_HIGHLIGHT_COLOR: &str = "var(--dxh-sand-highlight, #fff4c2)";
76pub const MIN_HOVERFX_SAND_ANIMATION_SPEED_MS: u16 = 16;
77pub const MAX_HOVERFX_SAND_ANIMATION_SPEED_MS: u16 = 4_000;
78pub const MIN_HOVERFX_TEXT_REVEAL_CYCLE_SPEED_MS: u16 = 16;
79pub const MAX_HOVERFX_TEXT_REVEAL_CYCLE_SPEED_MS: u16 = 2_000;
80pub const MAX_HOVERFX_TEXT_REVEAL_CHARSET_CHARS: usize = 64;
81pub const MAX_HOVERFX_TEXT_REVEAL_FONT_SIZE_PX: u16 = 96;
82pub const MAX_HOVERFX_TEXT_REVEAL_GAP_PX: u16 = 96;
83pub const SUPPORTED_HOVERFX_TEXTFX_EFFECTS: [&str; 7] = [
84    "scramble",
85    "typewriter",
86    "wave",
87    "glitch",
88    "mask-reveal",
89    "highlight-sweep",
90    "gradient-shift",
91];
92
93pub type HoverCfg = HoverFxConfig;
94pub type HoverDef = HoverFxDefinition;
95pub type HoverReg = HoverFxRegistry;
96pub type HoverPerf = HoverFxPerformanceConfig;
97
98pub fn hoverfx() -> HoverFxConfig {
99    HoverFxConfig::new()
100}
101
102pub fn hover_fx() -> HoverFxConfig {
103    HoverFxConfig::new()
104}
105
106pub fn hover_def(id: impl AsRef<str>, label: impl Into<String>) -> HoverFxDefinition {
107    HoverFxDefinition::new(id, label)
108}
109
110#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
111#[serde(rename_all = "kebab-case")]
112#[derive(Default)]
113pub enum HoverFxPreset {
114    #[default]
115    Spotlight,
116    SoftGlow,
117    BorderTrace,
118    Sheen,
119    ColorWash,
120    BinaryReveal,
121    TextureReveal,
122    Sand,
123    Tooltip,
124}
125
126impl HoverFxPreset {
127    pub const ALL: [Self; 9] = [
128        Self::Spotlight,
129        Self::SoftGlow,
130        Self::BorderTrace,
131        Self::Sheen,
132        Self::ColorWash,
133        Self::BinaryReveal,
134        Self::TextureReveal,
135        Self::Sand,
136        Self::Tooltip,
137    ];
138
139    pub const fn all() -> &'static [Self; 9] {
140        &Self::ALL
141    }
142
143    pub const fn as_attr(self) -> &'static str {
144        match self {
145            Self::Spotlight => "spotlight",
146            Self::SoftGlow => "soft-glow",
147            Self::BorderTrace => "border-trace",
148            Self::Sheen => "sheen",
149            Self::ColorWash => "color-wash",
150            Self::BinaryReveal => "binary-reveal",
151            Self::TextureReveal => "texture-reveal",
152            Self::Sand => "sand",
153            Self::Tooltip => "tooltip",
154        }
155    }
156
157    pub const fn label(self) -> &'static str {
158        match self {
159            Self::Spotlight => "Spotlight",
160            Self::SoftGlow => "Soft glow",
161            Self::BorderTrace => "Border trace",
162            Self::Sheen => "Sheen",
163            Self::ColorWash => "Color wash",
164            Self::BinaryReveal => "Binary reveal",
165            Self::TextureReveal => "Texture reveal",
166            Self::Sand => "Sand",
167            Self::Tooltip => "Tooltip",
168        }
169    }
170}
171
172#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
173#[serde(rename_all = "kebab-case")]
174#[derive(Default)]
175pub enum HoverFxTextAnimationSource {
176    HoverFx,
177    TextFx,
178    #[default]
179    Auto,
180}
181
182impl HoverFxTextAnimationSource {
183    pub const fn as_attr(self) -> &'static str {
184        match self {
185            Self::HoverFx => "hoverfx",
186            Self::TextFx => "textfx",
187            Self::Auto => "auto",
188        }
189    }
190}
191
192#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
193#[serde(rename_all = "kebab-case")]
194#[derive(Default)]
195pub enum HoverFxTextContrastMode {
196    #[default]
197    Off,
198    Auto,
199    Darken,
200    Invert,
201}
202
203impl HoverFxTextContrastMode {
204    pub const fn as_attr(self) -> &'static str {
205        match self {
206            Self::Off => "off",
207            Self::Auto => "auto",
208            Self::Darken => "darken",
209            Self::Invert => "invert",
210        }
211    }
212}
213
214#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
215#[serde(rename_all = "kebab-case")]
216#[derive(Default)]
217pub enum HoverFxTextRevealRenderer {
218    #[default]
219    GlyphAtlas,
220    CanvasGrid,
221}
222
223impl HoverFxTextRevealRenderer {
224    pub const fn as_attr(self) -> &'static str {
225        match self {
226            Self::GlyphAtlas => "glyph-atlas",
227            Self::CanvasGrid => "canvas-grid",
228        }
229    }
230}
231
232#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
233#[serde(rename_all = "kebab-case")]
234#[derive(Default)]
235pub enum HoverFxTextureRevealMode {
236    #[default]
237    Auto,
238    Halftone,
239    StaticGrain,
240}
241
242impl HoverFxTextureRevealMode {
243    pub const fn as_attr(self) -> &'static str {
244        match self {
245            Self::Auto => "auto",
246            Self::Halftone => "halftone",
247            Self::StaticGrain => "static-grain",
248        }
249    }
250}
251
252#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
253#[serde(rename_all = "camelCase")]
254pub struct HoverFxTextureRevealConfig {
255    pub mode: HoverFxTextureRevealMode,
256}
257
258impl Default for HoverFxTextureRevealConfig {
259    fn default() -> Self {
260        Self {
261            mode: HoverFxTextureRevealMode::Auto,
262        }
263    }
264}
265
266impl HoverFxTextureRevealConfig {
267    pub fn new() -> Self {
268        Self::default()
269    }
270
271    pub fn with_mode(mut self, mode: HoverFxTextureRevealMode) -> Self {
272        self.mode = mode;
273        self
274    }
275
276    pub fn to_json(&self) -> serde_json::Result<String> {
277        serde_json::to_string(self)
278    }
279}
280
281#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
282#[serde(rename_all = "kebab-case")]
283#[derive(Default)]
284pub enum HoverFxSandColorSource {
285    #[default]
286    Custom,
287    Element,
288}
289
290#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
291#[serde(rename_all = "camelCase")]
292pub struct HoverFxSandConfig {
293    pub grain_size_px: f32,
294    pub grain_density: f32,
295    pub shimmer_density: f32,
296    pub shimmer_strength: f32,
297    #[serde(default = "default_hoverfx_sand_shimmer_radius_px")]
298    pub shimmer_radius_px: f32,
299    pub specular_strength: f32,
300    pub roughness: f32,
301    pub animation_speed_ms: u16,
302    #[serde(default, skip_serializing_if = "is_default_hoverfx_sand_color_source")]
303    pub color_source: HoverFxSandColorSource,
304    pub color: String,
305    pub highlight_color: String,
306}
307
308impl Default for HoverFxSandConfig {
309    fn default() -> Self {
310        Self {
311            grain_size_px: DEFAULT_HOVERFX_SAND_GRAIN_SIZE_PX,
312            grain_density: DEFAULT_HOVERFX_SAND_GRAIN_DENSITY,
313            shimmer_density: DEFAULT_HOVERFX_SAND_SHIMMER_DENSITY,
314            shimmer_strength: DEFAULT_HOVERFX_SAND_SHIMMER_STRENGTH,
315            shimmer_radius_px: DEFAULT_HOVERFX_SAND_SHIMMER_RADIUS_PX,
316            specular_strength: DEFAULT_HOVERFX_SAND_SPECULAR_STRENGTH,
317            roughness: DEFAULT_HOVERFX_SAND_ROUGHNESS,
318            animation_speed_ms: DEFAULT_HOVERFX_SAND_ANIMATION_SPEED_MS,
319            color_source: HoverFxSandColorSource::Custom,
320            color: DEFAULT_HOVERFX_SAND_COLOR.to_string(),
321            highlight_color: DEFAULT_HOVERFX_SAND_HIGHLIGHT_COLOR.to_string(),
322        }
323    }
324}
325
326impl HoverFxSandConfig {
327    pub fn new() -> Self {
328        Self::default()
329    }
330
331    pub fn with_grain_size_px(mut self, grain_size_px: f32) -> Self {
332        self.grain_size_px = grain_size_px;
333        self
334    }
335
336    pub fn with_grain_density(mut self, grain_density: f32) -> Self {
337        self.grain_density = grain_density;
338        self
339    }
340
341    pub fn with_shimmer_density(mut self, shimmer_density: f32) -> Self {
342        self.shimmer_density = shimmer_density;
343        self
344    }
345
346    pub fn with_shimmer_strength(mut self, shimmer_strength: f32) -> Self {
347        self.shimmer_strength = shimmer_strength;
348        self
349    }
350
351    pub fn with_shimmer_radius_px(mut self, shimmer_radius_px: f32) -> Self {
352        self.shimmer_radius_px = shimmer_radius_px;
353        self
354    }
355
356    pub fn with_specular_strength(mut self, specular_strength: f32) -> Self {
357        self.specular_strength = specular_strength;
358        self
359    }
360
361    pub fn with_roughness(mut self, roughness: f32) -> Self {
362        self.roughness = roughness;
363        self
364    }
365
366    pub fn with_animation_speed_ms(mut self, animation_speed_ms: u16) -> Self {
367        self.animation_speed_ms = animation_speed_ms;
368        self
369    }
370
371    pub fn with_color_source(mut self, color_source: HoverFxSandColorSource) -> Self {
372        self.color_source = color_source;
373        self
374    }
375
376    pub fn with_color(mut self, color: impl Into<String>) -> Self {
377        self.color = color.into();
378        self
379    }
380
381    pub fn with_highlight_color(mut self, highlight_color: impl Into<String>) -> Self {
382        self.highlight_color = highlight_color.into();
383        self
384    }
385
386    pub fn to_json(&self) -> serde_json::Result<String> {
387        serde_json::to_string(self)
388    }
389}
390
391fn default_hoverfx_sand_shimmer_radius_px() -> f32 {
392    DEFAULT_HOVERFX_SAND_SHIMMER_RADIUS_PX
393}
394
395fn default_hoverfx_range_px() -> u16 {
396    DEFAULT_HOVERFX_RANGE_PX
397}
398
399fn is_default_hoverfx_sand_color_source(color_source: &HoverFxSandColorSource) -> bool {
400    *color_source == HoverFxSandColorSource::Custom
401}
402
403#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
404#[serde(rename_all = "kebab-case")]
405#[derive(Default)]
406pub enum HoverFxShape {
407    #[default]
408    Circle,
409    Square,
410    RoundedRect,
411    Polygon,
412}
413
414impl HoverFxShape {
415    pub const ALL: [Self; 4] = [Self::Circle, Self::Square, Self::RoundedRect, Self::Polygon];
416
417    pub const fn all() -> &'static [Self; 4] {
418        &Self::ALL
419    }
420
421    pub const fn as_attr(self) -> &'static str {
422        match self {
423            Self::Circle => "circle",
424            Self::Square => "square",
425            Self::RoundedRect => "rounded-rect",
426            Self::Polygon => "polygon",
427        }
428    }
429
430    pub const fn label(self) -> &'static str {
431        match self {
432            Self::Circle => "Circle",
433            Self::Square => "Square",
434            Self::RoundedRect => "Rounded rect",
435            Self::Polygon => "Polygon",
436        }
437    }
438}
439
440#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
441#[serde(rename_all = "kebab-case")]
442#[derive(Default)]
443pub enum HoverFxFalloff {
444    Hard,
445    Linear,
446    #[default]
447    Smooth,
448    Exponential,
449}
450
451impl HoverFxFalloff {
452    pub const ALL: [Self; 4] = [Self::Hard, Self::Linear, Self::Smooth, Self::Exponential];
453
454    pub const fn all() -> &'static [Self; 4] {
455        &Self::ALL
456    }
457
458    pub const fn as_attr(self) -> &'static str {
459        match self {
460            Self::Hard => "hard",
461            Self::Linear => "linear",
462            Self::Smooth => "smooth",
463            Self::Exponential => "exponential",
464        }
465    }
466
467    pub const fn label(self) -> &'static str {
468        match self {
469            Self::Hard => "Hard",
470            Self::Linear => "Linear",
471            Self::Smooth => "Smooth",
472            Self::Exponential => "Exponential",
473        }
474    }
475}
476
477#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
478#[serde(rename_all = "kebab-case")]
479#[derive(Default)]
480pub enum HoverFxRenderer {
481    #[default]
482    WorkerFirst,
483    CssOnly,
484    Disabled,
485}
486
487impl HoverFxRenderer {
488    pub const fn as_attr(self) -> &'static str {
489        match self {
490            Self::WorkerFirst => "worker-first",
491            Self::CssOnly => "css-only",
492            Self::Disabled => "disabled",
493        }
494    }
495
496    pub const fn uses_worker(self) -> bool {
497        matches!(self, Self::WorkerFirst)
498    }
499}
500
501#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
502#[serde(rename_all = "camelCase")]
503pub struct HoverFxTextRevealConfig {
504    pub charset: String,
505    pub cycle: bool,
506    pub cycle_speed_ms: u16,
507    pub density: f32,
508    pub font_size_px: u16,
509    pub gap_px: u16,
510    pub font_family: String,
511    pub color: String,
512    pub animation_source: HoverFxTextAnimationSource,
513    #[serde(default)]
514    pub renderer: HoverFxTextRevealRenderer,
515    pub textfx_effect: String,
516}
517
518impl Default for HoverFxTextRevealConfig {
519    fn default() -> Self {
520        Self {
521            charset: DEFAULT_HOVERFX_TEXT_REVEAL_CHARSET.to_string(),
522            cycle: true,
523            cycle_speed_ms: DEFAULT_HOVERFX_TEXT_REVEAL_CYCLE_SPEED_MS,
524            density: DEFAULT_HOVERFX_TEXT_REVEAL_DENSITY,
525            font_size_px: DEFAULT_HOVERFX_TEXT_REVEAL_FONT_SIZE_PX,
526            gap_px: DEFAULT_HOVERFX_TEXT_REVEAL_GAP_PX,
527            font_family: DEFAULT_HOVERFX_TEXT_REVEAL_FONT_FAMILY.to_string(),
528            color: DEFAULT_HOVERFX_TEXT_REVEAL_COLOR.to_string(),
529            animation_source: HoverFxTextAnimationSource::Auto,
530            renderer: HoverFxTextRevealRenderer::GlyphAtlas,
531            textfx_effect: DEFAULT_HOVERFX_TEXT_REVEAL_TEXTFX_EFFECT.to_string(),
532        }
533    }
534}
535
536impl HoverFxTextRevealConfig {
537    pub fn new() -> Self {
538        Self::default()
539    }
540
541    pub fn with_charset(mut self, charset: impl Into<String>) -> Self {
542        self.charset = charset.into();
543        self
544    }
545
546    pub fn with_cycle(mut self, cycle: bool) -> Self {
547        self.cycle = cycle;
548        self
549    }
550
551    pub fn with_cycle_speed_ms(mut self, cycle_speed_ms: u16) -> Self {
552        self.cycle_speed_ms = cycle_speed_ms;
553        self
554    }
555
556    pub fn with_density(mut self, density: f32) -> Self {
557        self.density = density;
558        self
559    }
560
561    pub fn with_font_size_px(mut self, font_size_px: u16) -> Self {
562        self.font_size_px = font_size_px;
563        self
564    }
565
566    pub fn with_gap_px(mut self, gap_px: u16) -> Self {
567        self.gap_px = gap_px;
568        self
569    }
570
571    pub fn with_font_family(mut self, font_family: impl Into<String>) -> Self {
572        self.font_family = font_family.into();
573        self
574    }
575
576    pub fn with_color(mut self, color: impl Into<String>) -> Self {
577        self.color = color.into();
578        self
579    }
580
581    pub fn with_animation_source(mut self, animation_source: HoverFxTextAnimationSource) -> Self {
582        self.animation_source = animation_source;
583        self
584    }
585
586    pub fn with_renderer(mut self, renderer: HoverFxTextRevealRenderer) -> Self {
587        self.renderer = renderer;
588        self
589    }
590
591    pub fn with_textfx_effect(mut self, textfx_effect: impl AsRef<str>) -> Self {
592        self.textfx_effect = hoverfx_id(textfx_effect);
593        self
594    }
595
596    #[cfg(feature = "textfx-interop")]
597    pub fn with_textfx_effect_enum(mut self, effect: dioxus_textfx_core::TextFxEffect) -> Self {
598        self.textfx_effect = effect.as_attr().to_string();
599        self
600    }
601
602    pub fn to_json(&self) -> serde_json::Result<String> {
603        serde_json::to_string(self)
604    }
605
606    pub fn to_textfx_config_json(
607        &self,
608        id: impl AsRef<str>,
609        text: impl AsRef<str>,
610    ) -> serde_json::Result<String> {
611        serde_json::to_string(&serde_json::json!({
612            "id": hoverfx_id(id),
613            "text": text.as_ref(),
614            "effect": self.textfx_effect,
615            "timing": {
616                "durationMs": 640,
617                "speedMs": self.cycle_speed_ms,
618                "staggerMs": 16,
619            },
620            "split": "chars",
621            "trigger": "hover",
622            "charset": self.charset,
623            "reducedMotion": "instant",
624            "performanceProfile": "interactive",
625        }))
626    }
627}
628
629#[cfg(feature = "textfx-interop")]
630impl From<dioxus_textfx_core::TextFxEffect> for HoverFxTextRevealConfig {
631    fn from(effect: dioxus_textfx_core::TextFxEffect) -> Self {
632        Self::default().with_textfx_effect_enum(effect)
633    }
634}
635
636#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
637#[serde(rename_all = "kebab-case")]
638pub enum HoverFxTooltipPlacement {
639    Top,
640    Right,
641    Bottom,
642    Left,
643    #[default]
644    Cursor,
645}
646
647impl HoverFxTooltipPlacement {
648    pub const fn as_attr(self) -> &'static str {
649        match self {
650            Self::Top => "top",
651            Self::Right => "right",
652            Self::Bottom => "bottom",
653            Self::Left => "left",
654            Self::Cursor => "cursor",
655        }
656    }
657}
658
659fn default_hoverfx_tooltip_box_opacity() -> f32 {
660    DEFAULT_HOVERFX_TOOLTIP_BOX_OPACITY
661}
662
663#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
664#[serde(rename_all = "camelCase")]
665pub struct HoverFxTooltipConfig {
666    #[serde(default, skip_serializing_if = "String::is_empty")]
667    pub text: String,
668    #[serde(default, skip_serializing_if = "Option::is_none")]
669    pub i18n_key: Option<String>,
670    pub placement: HoverFxTooltipPlacement,
671    pub offset_px: u16,
672    pub max_width_px: u16,
673    #[serde(default = "default_hoverfx_tooltip_box_opacity")]
674    pub box_opacity: f32,
675    pub show_delay_ms: u16,
676    pub hide_delay_ms: u16,
677    pub duration_ms: u16,
678    pub speed_ms: u16,
679    pub stagger_ms: u16,
680    pub textfx_effect: String,
681    pub split: String,
682}
683
684impl Default for HoverFxTooltipConfig {
685    fn default() -> Self {
686        Self {
687            text: String::new(),
688            i18n_key: None,
689            placement: HoverFxTooltipPlacement::Cursor,
690            offset_px: DEFAULT_HOVERFX_TOOLTIP_OFFSET_PX,
691            max_width_px: DEFAULT_HOVERFX_TOOLTIP_MAX_WIDTH_PX,
692            box_opacity: DEFAULT_HOVERFX_TOOLTIP_BOX_OPACITY,
693            show_delay_ms: DEFAULT_HOVERFX_TOOLTIP_SHOW_DELAY_MS,
694            hide_delay_ms: DEFAULT_HOVERFX_TOOLTIP_HIDE_DELAY_MS,
695            duration_ms: DEFAULT_HOVERFX_TOOLTIP_DURATION_MS,
696            speed_ms: DEFAULT_HOVERFX_TOOLTIP_SPEED_MS,
697            stagger_ms: DEFAULT_HOVERFX_TOOLTIP_STAGGER_MS,
698            textfx_effect: DEFAULT_HOVERFX_TOOLTIP_TEXTFX_EFFECT.to_string(),
699            split: DEFAULT_HOVERFX_TOOLTIP_SPLIT.to_string(),
700        }
701    }
702}
703
704impl HoverFxTooltipConfig {
705    pub fn new(text: impl Into<String>) -> Self {
706        Self::default().with_text(text)
707    }
708
709    pub fn with_text(mut self, text: impl Into<String>) -> Self {
710        self.text = text.into();
711        self
712    }
713
714    pub fn with_i18n_key(mut self, key: impl AsRef<str>) -> Self {
715        let key = key.as_ref().trim();
716        self.i18n_key = if key.is_empty() {
717            None
718        } else {
719            Some(key.to_string())
720        };
721        self
722    }
723
724    pub fn i18n_key(self, key: impl AsRef<str>) -> Self {
725        self.with_i18n_key(key)
726    }
727
728    pub fn key(self, key: impl AsRef<str>) -> Self {
729        self.with_i18n_key(key)
730    }
731
732    pub fn locale_key(self, key: impl AsRef<str>) -> Self {
733        self.with_i18n_key(key)
734    }
735
736    pub fn with_placement(mut self, placement: HoverFxTooltipPlacement) -> Self {
737        self.placement = placement;
738        self
739    }
740
741    pub fn top(self) -> Self {
742        self.with_placement(HoverFxTooltipPlacement::Top)
743    }
744
745    pub fn right(self) -> Self {
746        self.with_placement(HoverFxTooltipPlacement::Right)
747    }
748
749    pub fn bottom(self) -> Self {
750        self.with_placement(HoverFxTooltipPlacement::Bottom)
751    }
752
753    pub fn left(self) -> Self {
754        self.with_placement(HoverFxTooltipPlacement::Left)
755    }
756
757    pub fn cursor(self) -> Self {
758        self.with_placement(HoverFxTooltipPlacement::Cursor)
759    }
760
761    pub fn with_offset_px(mut self, offset_px: u16) -> Self {
762        self.offset_px = offset_px;
763        self
764    }
765
766    pub fn offset(self, offset_px: u16) -> Self {
767        self.with_offset_px(offset_px)
768    }
769
770    pub fn with_max_width_px(mut self, max_width_px: u16) -> Self {
771        self.max_width_px = max_width_px;
772        self
773    }
774
775    pub fn max_width(self, max_width_px: u16) -> Self {
776        self.with_max_width_px(max_width_px)
777    }
778
779    pub fn with_box_opacity(mut self, box_opacity: f32) -> Self {
780        self.box_opacity = box_opacity;
781        self
782    }
783
784    pub fn box_opacity(self, box_opacity: f32) -> Self {
785        self.with_box_opacity(box_opacity)
786    }
787
788    pub fn opacity(self, box_opacity: f32) -> Self {
789        self.with_box_opacity(box_opacity)
790    }
791
792    pub fn with_show_delay_ms(mut self, show_delay_ms: u16) -> Self {
793        self.show_delay_ms = show_delay_ms;
794        self
795    }
796
797    pub fn show_delay_ms(self, show_delay_ms: u16) -> Self {
798        self.with_show_delay_ms(show_delay_ms)
799    }
800
801    pub fn with_hide_delay_ms(mut self, hide_delay_ms: u16) -> Self {
802        self.hide_delay_ms = hide_delay_ms;
803        self
804    }
805
806    pub fn hide_delay_ms(self, hide_delay_ms: u16) -> Self {
807        self.with_hide_delay_ms(hide_delay_ms)
808    }
809
810    pub fn with_duration_ms(mut self, duration_ms: u16) -> Self {
811        self.duration_ms = duration_ms;
812        self
813    }
814
815    pub fn dur_ms(self, duration_ms: u16) -> Self {
816        self.with_duration_ms(duration_ms)
817    }
818
819    pub fn with_speed_ms(mut self, speed_ms: u16) -> Self {
820        self.speed_ms = speed_ms;
821        self
822    }
823
824    pub fn speed_ms(self, speed_ms: u16) -> Self {
825        self.with_speed_ms(speed_ms)
826    }
827
828    pub fn with_stagger_ms(mut self, stagger_ms: u16) -> Self {
829        self.stagger_ms = stagger_ms;
830        self
831    }
832
833    pub fn stagger_ms(self, stagger_ms: u16) -> Self {
834        self.with_stagger_ms(stagger_ms)
835    }
836
837    pub fn with_textfx_effect(mut self, textfx_effect: impl AsRef<str>) -> Self {
838        self.textfx_effect = hoverfx_id(textfx_effect);
839        self
840    }
841
842    #[cfg(feature = "textfx-interop")]
843    pub fn with_textfx_effect_enum(mut self, effect: dioxus_textfx_core::TextFxEffect) -> Self {
844        self.textfx_effect = effect.as_attr().to_string();
845        self
846    }
847
848    pub fn scramble(self) -> Self {
849        self.with_textfx_effect("scramble")
850    }
851
852    pub fn typewriter(self) -> Self {
853        self.with_textfx_effect("typewriter")
854    }
855
856    pub fn wave(self) -> Self {
857        self.with_textfx_effect("wave")
858    }
859
860    pub fn glitch(self) -> Self {
861        self.with_textfx_effect("glitch")
862    }
863
864    pub fn with_split(mut self, split: impl Into<String>) -> Self {
865        self.split = split.into();
866        self
867    }
868
869    pub fn split_chars(self) -> Self {
870        self.with_split("chars")
871    }
872
873    pub fn split_words(self) -> Self {
874        self.with_split("words")
875    }
876
877    pub fn to_json(&self) -> serde_json::Result<String> {
878        serde_json::to_string(self)
879    }
880
881    pub fn to_textfx_config_json(
882        &self,
883        id: impl AsRef<str>,
884        fallback_text: impl AsRef<str>,
885    ) -> serde_json::Result<String> {
886        let text = if self.text.trim().is_empty() {
887            fallback_text.as_ref()
888        } else {
889            self.text.as_str()
890        };
891        serde_json::to_string(&serde_json::json!({
892            "id": hoverfx_id(id),
893            "text": text,
894            "effect": self.textfx_effect,
895            "timing": {
896                "durationMs": self.duration_ms,
897                "speedMs": self.speed_ms,
898                "staggerMs": self.stagger_ms,
899            },
900            "split": self.split,
901            "trigger": "manual",
902            "reducedMotion": "instant",
903            "performanceProfile": "interactive",
904        }))
905    }
906}
907
908#[cfg(feature = "textfx-interop")]
909impl From<dioxus_textfx_core::TextFxEffect> for HoverFxTooltipConfig {
910    fn from(effect: dioxus_textfx_core::TextFxEffect) -> Self {
911        Self::default().with_textfx_effect_enum(effect)
912    }
913}
914
915#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
916#[serde(rename_all = "kebab-case")]
917pub enum HoverFxValidationSeverity {
918    Error,
919    Warning,
920}
921
922#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
923#[serde(rename_all = "kebab-case")]
924pub enum HoverFxValidationCode {
925    MissingDefaultEffect,
926    InvalidDefaultEffectId,
927    InvalidEffectId,
928    EmptyRuntimePath,
929    EmptyWorkerPath,
930    InvalidRadius,
931    InvalidRange,
932    InvalidStrength,
933    InvalidSmoothing,
934    InvalidMaxActiveElements,
935    InvalidCssVariableName,
936    UnsafeCssValue,
937    UnsafeCustomShapeValue,
938    InvalidTextRevealCharset,
939    InvalidTextRevealCycleSpeed,
940    InvalidTextRevealMetric,
941    UnsafeTextRevealCssValue,
942    UnsupportedTextFxEffect,
943    InvalidTooltipMetric,
944    InvalidTooltipText,
945    InvalidTooltipI18nKey,
946    InvalidSandMetric,
947    InvalidSandAnimationSpeed,
948    UnsafeSandCssValue,
949    InvalidPerformanceMetric,
950}
951
952#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
953#[serde(rename_all = "camelCase")]
954pub struct HoverFxValidationIssue {
955    pub severity: HoverFxValidationSeverity,
956    pub code: HoverFxValidationCode,
957    pub message: String,
958    #[serde(default, skip_serializing_if = "Option::is_none")]
959    pub field: Option<String>,
960    #[serde(default, skip_serializing_if = "Option::is_none")]
961    pub effect: Option<String>,
962}
963
964impl HoverFxValidationIssue {
965    pub fn error(
966        code: HoverFxValidationCode,
967        field: impl Into<String>,
968        message: impl Into<String>,
969    ) -> Self {
970        Self {
971            severity: HoverFxValidationSeverity::Error,
972            code,
973            message: message.into(),
974            field: Some(field.into()),
975            effect: None,
976        }
977    }
978
979    pub fn effect_error(
980        code: HoverFxValidationCode,
981        effect: impl Into<String>,
982        field: impl Into<String>,
983        message: impl Into<String>,
984    ) -> Self {
985        Self {
986            severity: HoverFxValidationSeverity::Error,
987            code,
988            message: message.into(),
989            field: Some(field.into()),
990            effect: Some(effect.into()),
991        }
992    }
993}
994
995#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
996#[serde(rename_all = "camelCase")]
997pub struct HoverFxValidationReport {
998    pub issues: Vec<HoverFxValidationIssue>,
999}
1000
1001impl HoverFxValidationReport {
1002    pub fn is_valid(&self) -> bool {
1003        self.issues
1004            .iter()
1005            .all(|issue| issue.severity != HoverFxValidationSeverity::Error)
1006    }
1007
1008    pub fn errors(&self) -> impl Iterator<Item = &HoverFxValidationIssue> {
1009        self.issues
1010            .iter()
1011            .filter(|issue| issue.severity == HoverFxValidationSeverity::Error)
1012    }
1013
1014    pub fn warnings(&self) -> impl Iterator<Item = &HoverFxValidationIssue> {
1015        self.issues
1016            .iter()
1017            .filter(|issue| issue.severity == HoverFxValidationSeverity::Warning)
1018    }
1019
1020    pub fn push(&mut self, issue: HoverFxValidationIssue) {
1021        self.issues.push(issue);
1022    }
1023}
1024
1025#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1026#[serde(rename_all = "camelCase")]
1027pub struct HoverFxDefinition {
1028    pub id: String,
1029    pub label: String,
1030    #[serde(default, skip_serializing_if = "Option::is_none")]
1031    pub preset: Option<HoverFxPreset>,
1032    #[serde(default, skip_serializing_if = "Option::is_none")]
1033    pub radius_px: Option<u16>,
1034    #[serde(default, skip_serializing_if = "Option::is_none")]
1035    pub range_px: Option<u16>,
1036    #[serde(default, skip_serializing_if = "Option::is_none")]
1037    pub shape: Option<HoverFxShape>,
1038    #[serde(default, skip_serializing_if = "Option::is_none")]
1039    pub falloff: Option<HoverFxFalloff>,
1040    #[serde(default, skip_serializing_if = "Option::is_none")]
1041    pub strength: Option<f32>,
1042    #[serde(default, skip_serializing_if = "Option::is_none")]
1043    pub smoothing: Option<f32>,
1044    #[serde(default, skip_serializing_if = "Option::is_none")]
1045    pub custom_shape: Option<String>,
1046    #[serde(default, skip_serializing_if = "Option::is_none")]
1047    pub text_reveal: Option<HoverFxTextRevealConfig>,
1048    #[serde(default, skip_serializing_if = "Option::is_none")]
1049    pub texture_reveal: Option<HoverFxTextureRevealConfig>,
1050    #[serde(default, skip_serializing_if = "Option::is_none")]
1051    pub sand: Option<HoverFxSandConfig>,
1052    #[serde(default, skip_serializing_if = "Option::is_none")]
1053    pub tooltip: Option<HoverFxTooltipConfig>,
1054    #[serde(default, skip_serializing_if = "Option::is_none")]
1055    pub text_contrast: Option<HoverFxTextContrastMode>,
1056    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1057    pub css_vars: BTreeMap<String, String>,
1058}
1059
1060impl HoverFxDefinition {
1061    pub fn new(id: impl AsRef<str>, label: impl Into<String>) -> Self {
1062        Self {
1063            id: hoverfx_id(id),
1064            label: label.into(),
1065            preset: None,
1066            radius_px: None,
1067            range_px: None,
1068            shape: None,
1069            falloff: None,
1070            strength: None,
1071            smoothing: None,
1072            custom_shape: None,
1073            text_reveal: None,
1074            texture_reveal: None,
1075            sand: None,
1076            tooltip: None,
1077            text_contrast: None,
1078            css_vars: BTreeMap::new(),
1079        }
1080    }
1081
1082    pub fn from_preset(preset: HoverFxPreset) -> Self {
1083        match preset {
1084            HoverFxPreset::Spotlight => Self::new(preset.as_attr(), preset.label())
1085                .with_preset(preset)
1086                .with_shape(HoverFxShape::Circle)
1087                .with_falloff(HoverFxFalloff::Smooth)
1088                .with_css_var("--dxh-color", "rgba(255,255,255,0.32)")
1089                .with_css_var("--dxh-blend-mode", "screen"),
1090            HoverFxPreset::SoftGlow => Self::new(preset.as_attr(), preset.label())
1091                .with_preset(preset)
1092                .with_radius_px(220)
1093                .with_shape(HoverFxShape::Circle)
1094                .with_falloff(HoverFxFalloff::Smooth)
1095                .with_strength(0.85)
1096                .with_css_var("--dxh-color", "rgba(56,189,248,0.26)")
1097                .with_css_var("--dxh-blur", "28px"),
1098            HoverFxPreset::BorderTrace => Self::new(preset.as_attr(), preset.label())
1099                .with_preset(preset)
1100                .with_shape(HoverFxShape::RoundedRect)
1101                .with_falloff(HoverFxFalloff::Exponential)
1102                .with_css_var("--dxh-border-color", "rgba(125,211,252,0.82)")
1103                .with_css_var("--dxh-border-width", "1px"),
1104            HoverFxPreset::Sheen => Self::new(preset.as_attr(), preset.label())
1105                .with_preset(preset)
1106                .with_shape(HoverFxShape::Square)
1107                .with_falloff(HoverFxFalloff::Linear)
1108                .with_css_var("--dxh-angle", "115deg")
1109                .with_css_var("--dxh-color", "rgba(255,255,255,0.36)"),
1110            HoverFxPreset::ColorWash => Self::new(preset.as_attr(), preset.label())
1111                .with_preset(preset)
1112                .with_radius_px(240)
1113                .with_shape(HoverFxShape::Circle)
1114                .with_falloff(HoverFxFalloff::Smooth)
1115                .with_css_var("--dxh-color", "rgba(14,165,233,0.22)")
1116                .with_css_var("--dxh-accent-color", "rgba(217,70,239,0.18)"),
1117            HoverFxPreset::BinaryReveal => Self::new(preset.as_attr(), preset.label())
1118                .with_preset(preset)
1119                .with_radius_px(300)
1120                .with_shape(HoverFxShape::Circle)
1121                .with_falloff(HoverFxFalloff::Smooth)
1122                .with_strength(1.15)
1123                .with_text_reveal(HoverFxTextRevealConfig::default())
1124                .with_css_var("--dxh-binary-color", DEFAULT_HOVERFX_TEXT_REVEAL_COLOR),
1125            HoverFxPreset::TextureReveal => Self::new(preset.as_attr(), preset.label())
1126                .with_preset(preset)
1127                .with_radius_px(340)
1128                .with_shape(HoverFxShape::Circle)
1129                .with_falloff(HoverFxFalloff::Smooth)
1130                .with_strength(1.1)
1131                .with_texture_reveal(HoverFxTextureRevealConfig::default()),
1132            HoverFxPreset::Sand => Self::new(preset.as_attr(), preset.label())
1133                .with_preset(preset)
1134                .with_radius_px(320)
1135                .with_shape(HoverFxShape::Circle)
1136                .with_falloff(HoverFxFalloff::Smooth)
1137                .with_strength(1.1)
1138                .with_sand(HoverFxSandConfig::default())
1139                .with_css_var("--dxh-sand-color", "#d7b878")
1140                .with_css_var("--dxh-sand-highlight", "#fff4c2"),
1141            HoverFxPreset::Tooltip => Self::new(preset.as_attr(), preset.label())
1142                .with_preset(preset)
1143                .with_radius_px(96)
1144                .with_range_px(0)
1145                .with_shape(HoverFxShape::RoundedRect)
1146                .with_falloff(HoverFxFalloff::Smooth)
1147                .with_strength(0.0)
1148                .with_tooltip(HoverFxTooltipConfig::default()),
1149        }
1150    }
1151
1152    pub fn with_label(mut self, label: impl Into<String>) -> Self {
1153        self.label = label.into();
1154        self
1155    }
1156
1157    pub fn with_preset(mut self, preset: HoverFxPreset) -> Self {
1158        self.preset = Some(preset);
1159        self
1160    }
1161
1162    pub fn with_radius_px(mut self, radius_px: u16) -> Self {
1163        self.radius_px = Some(radius_px);
1164        self
1165    }
1166
1167    pub fn radius(self, radius_px: u16) -> Self {
1168        self.with_radius_px(radius_px)
1169    }
1170
1171    pub fn radius_px(self, radius_px: u16) -> Self {
1172        self.with_radius_px(radius_px)
1173    }
1174
1175    pub fn with_range_px(mut self, range_px: u16) -> Self {
1176        self.range_px = Some(range_px);
1177        self
1178    }
1179
1180    pub fn range(self, range_px: u16) -> Self {
1181        self.with_range_px(range_px)
1182    }
1183
1184    pub fn range_px(self, range_px: u16) -> Self {
1185        self.with_range_px(range_px)
1186    }
1187
1188    pub fn activation_range(self, range_px: u16) -> Self {
1189        self.with_range_px(range_px)
1190    }
1191
1192    pub fn with_shape(mut self, shape: HoverFxShape) -> Self {
1193        self.shape = Some(shape);
1194        self
1195    }
1196
1197    pub fn with_falloff(mut self, falloff: HoverFxFalloff) -> Self {
1198        self.falloff = Some(falloff);
1199        self
1200    }
1201
1202    pub fn with_strength(mut self, strength: f32) -> Self {
1203        self.strength = Some(strength);
1204        self
1205    }
1206
1207    pub fn with_smoothing(mut self, smoothing: f32) -> Self {
1208        self.smoothing = Some(smoothing);
1209        self
1210    }
1211
1212    pub fn with_custom_shape(mut self, custom_shape: impl Into<String>) -> Self {
1213        self.custom_shape = Some(custom_shape.into());
1214        self
1215    }
1216
1217    pub fn with_text_reveal(mut self, text_reveal: HoverFxTextRevealConfig) -> Self {
1218        self.text_reveal = Some(text_reveal);
1219        self
1220    }
1221
1222    pub fn with_texture_reveal(mut self, texture_reveal: HoverFxTextureRevealConfig) -> Self {
1223        self.texture_reveal = Some(texture_reveal);
1224        self
1225    }
1226
1227    pub fn with_sand(mut self, sand: HoverFxSandConfig) -> Self {
1228        self.sand = Some(sand);
1229        self
1230    }
1231
1232    pub fn with_tooltip(mut self, tooltip: HoverFxTooltipConfig) -> Self {
1233        self.tooltip = Some(tooltip);
1234        self
1235    }
1236
1237    pub fn with_text_contrast(mut self, text_contrast: HoverFxTextContrastMode) -> Self {
1238        self.text_contrast = Some(text_contrast);
1239        self
1240    }
1241
1242    pub fn with_css_var(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
1243        self.css_vars.insert(name.into(), value.into());
1244        self
1245    }
1246
1247    pub fn with_css_vars<I, K, V>(mut self, css_vars: I) -> Self
1248    where
1249        I: IntoIterator<Item = (K, V)>,
1250        K: Into<String>,
1251        V: Into<String>,
1252    {
1253        for (name, value) in css_vars {
1254            self.css_vars.insert(name.into(), value.into());
1255        }
1256        self
1257    }
1258}
1259
1260#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1261#[serde(rename_all = "camelCase")]
1262pub struct HoverFxRegistry {
1263    pub effects: Vec<HoverFxDefinition>,
1264}
1265
1266impl Default for HoverFxRegistry {
1267    fn default() -> Self {
1268        Self::defaults()
1269    }
1270}
1271
1272impl HoverFxRegistry {
1273    pub fn new() -> Self {
1274        Self {
1275            effects: Vec::new(),
1276        }
1277    }
1278
1279    pub fn defaults() -> Self {
1280        let mut registry = Self::new();
1281        for preset in HoverFxPreset::all() {
1282            registry.insert_effect(HoverFxDefinition::from_preset(*preset));
1283        }
1284        registry
1285    }
1286
1287    pub fn with_effect(mut self, effect: HoverFxDefinition) -> Self {
1288        self.insert_effect(effect);
1289        self
1290    }
1291
1292    pub fn with_effects<I>(mut self, effects: I) -> Self
1293    where
1294        I: IntoIterator<Item = HoverFxDefinition>,
1295    {
1296        for effect in effects {
1297            self.insert_effect(effect);
1298        }
1299        self
1300    }
1301
1302    pub fn insert_effect(&mut self, effect: HoverFxDefinition) -> Option<HoverFxDefinition> {
1303        if let Some(existing) = self
1304            .effects
1305            .iter_mut()
1306            .find(|candidate| candidate.id == effect.id)
1307        {
1308            return Some(std::mem::replace(existing, effect));
1309        }
1310        self.effects.push(effect);
1311        None
1312    }
1313
1314    pub fn contains_effect(&self, id: impl AsRef<str>) -> bool {
1315        let id = hoverfx_id(id);
1316        self.effects.iter().any(|effect| effect.id == id)
1317    }
1318
1319    pub fn effect(&self, id: impl AsRef<str>) -> Option<&HoverFxDefinition> {
1320        let id = hoverfx_id(id);
1321        self.effects.iter().find(|effect| effect.id == id)
1322    }
1323
1324    pub fn effect_ids(&self) -> Vec<&str> {
1325        self.effects
1326            .iter()
1327            .map(|effect| effect.id.as_str())
1328            .collect()
1329    }
1330}
1331
1332#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1333#[serde(rename_all = "camelCase")]
1334pub struct HoverFxPerformanceConfig {
1335    pub lazy_local_layers: bool,
1336    pub worker_local_layers: bool,
1337    pub dirty_rect_rendering: bool,
1338    pub shader_texture_cache: bool,
1339    pub dpr_cap: f32,
1340    pub idle_release_timeout_ms: u16,
1341    #[serde(default, skip_serializing_if = "Option::is_none")]
1342    pub candidate_observer_margin_px: Option<u16>,
1343    #[serde(default, skip_serializing_if = "Option::is_none")]
1344    pub motion_lane: Option<String>,
1345    #[serde(default, skip_serializing_if = "Option::is_none")]
1346    pub motion_scope: Option<String>,
1347    #[serde(default, skip_serializing_if = "Option::is_none")]
1348    pub view_transition_name_isolation: Option<String>,
1349}
1350
1351impl Default for HoverFxPerformanceConfig {
1352    fn default() -> Self {
1353        Self {
1354            lazy_local_layers: DEFAULT_HOVERFX_PERF_LAZY_LOCAL_LAYERS,
1355            worker_local_layers: DEFAULT_HOVERFX_PERF_WORKER_LOCAL_LAYERS,
1356            dirty_rect_rendering: DEFAULT_HOVERFX_PERF_DIRTY_RECT_RENDERING,
1357            shader_texture_cache: DEFAULT_HOVERFX_PERF_SHADER_TEXTURE_CACHE,
1358            dpr_cap: DEFAULT_HOVERFX_PERF_DPR_CAP,
1359            idle_release_timeout_ms: DEFAULT_HOVERFX_PERF_IDLE_RELEASE_TIMEOUT_MS,
1360            candidate_observer_margin_px: None,
1361            motion_lane: None,
1362            motion_scope: None,
1363            view_transition_name_isolation: None,
1364        }
1365    }
1366}
1367
1368impl HoverFxPerformanceConfig {
1369    pub fn new() -> Self {
1370        Self::default()
1371    }
1372
1373    pub fn with_lazy_local_layers(mut self, lazy_local_layers: bool) -> Self {
1374        self.lazy_local_layers = lazy_local_layers;
1375        self
1376    }
1377
1378    pub fn with_worker_local_layers(mut self, worker_local_layers: bool) -> Self {
1379        self.worker_local_layers = worker_local_layers;
1380        self
1381    }
1382
1383    pub fn with_dirty_rect_rendering(mut self, dirty_rect_rendering: bool) -> Self {
1384        self.dirty_rect_rendering = dirty_rect_rendering;
1385        self
1386    }
1387
1388    pub fn with_shader_texture_cache(mut self, shader_texture_cache: bool) -> Self {
1389        self.shader_texture_cache = shader_texture_cache;
1390        self
1391    }
1392
1393    pub fn with_dpr_cap(mut self, dpr_cap: f32) -> Self {
1394        self.dpr_cap = dpr_cap;
1395        self
1396    }
1397
1398    pub fn with_idle_release_timeout_ms(mut self, idle_release_timeout_ms: u16) -> Self {
1399        self.idle_release_timeout_ms = idle_release_timeout_ms;
1400        self
1401    }
1402
1403    pub fn with_candidate_observer_margin_px(mut self, candidate_observer_margin_px: u16) -> Self {
1404        self.candidate_observer_margin_px = Some(candidate_observer_margin_px);
1405        self
1406    }
1407
1408    #[cfg(feature = "viewtx-interop")]
1409    pub fn with_viewtx_motion_policy(
1410        mut self,
1411        policy: &dioxus_viewtx_core::ViewMotionPolicy,
1412    ) -> Self {
1413        self.motion_lane = Some(policy.lane.as_attr().to_string());
1414        self.motion_scope = Some(policy.scope.as_attr().to_string());
1415        self.view_transition_name_isolation =
1416            Some(policy.view_transition_name_isolation.as_attr().to_string());
1417        self
1418    }
1419}
1420
1421#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1422#[serde(rename_all = "camelCase")]
1423pub struct HoverFxConfig {
1424    pub registry: HoverFxRegistry,
1425    pub default_effect: String,
1426    pub radius_px: u16,
1427    #[serde(default = "default_hoverfx_range_px")]
1428    pub range_px: u16,
1429    pub shape: HoverFxShape,
1430    pub falloff: HoverFxFalloff,
1431    pub strength: f32,
1432    pub smoothing: f32,
1433    pub max_active_elements: u16,
1434    pub renderer: HoverFxRenderer,
1435    pub runtime_path: String,
1436    pub worker_path: String,
1437    #[serde(default)]
1438    pub performance: HoverFxPerformanceConfig,
1439}
1440
1441impl Default for HoverFxConfig {
1442    fn default() -> Self {
1443        Self::new()
1444    }
1445}
1446
1447impl HoverFxConfig {
1448    pub fn new() -> Self {
1449        Self {
1450            registry: HoverFxRegistry::default(),
1451            default_effect: HoverFxPreset::Spotlight.as_attr().to_string(),
1452            radius_px: DEFAULT_HOVERFX_RADIUS_PX,
1453            range_px: DEFAULT_HOVERFX_RANGE_PX,
1454            shape: HoverFxShape::Circle,
1455            falloff: HoverFxFalloff::Smooth,
1456            strength: DEFAULT_HOVERFX_STRENGTH,
1457            smoothing: DEFAULT_HOVERFX_SMOOTHING,
1458            max_active_elements: DEFAULT_HOVERFX_MAX_ACTIVE_ELEMENTS,
1459            renderer: HoverFxRenderer::WorkerFirst,
1460            runtime_path: DEFAULT_HOVERFX_RUNTIME_PATH.to_string(),
1461            worker_path: DEFAULT_HOVERFX_WORKER_PATH.to_string(),
1462            performance: HoverFxPerformanceConfig::default(),
1463        }
1464    }
1465
1466    pub fn with_registry(mut self, registry: HoverFxRegistry) -> Self {
1467        self.registry = registry;
1468        self
1469    }
1470
1471    pub fn registry(self, registry: HoverFxRegistry) -> Self {
1472        self.with_registry(registry)
1473    }
1474
1475    pub fn with_effect(mut self, effect: HoverFxDefinition) -> Self {
1476        self.registry.insert_effect(effect);
1477        self
1478    }
1479
1480    pub fn effect(self, effect: HoverFxDefinition) -> Self {
1481        self.with_effect(effect)
1482    }
1483
1484    pub fn with_effects<I>(mut self, effects: I) -> Self
1485    where
1486        I: IntoIterator<Item = HoverFxDefinition>,
1487    {
1488        for effect in effects {
1489            self.registry.insert_effect(effect);
1490        }
1491        self
1492    }
1493
1494    pub fn effects<I>(self, effects: I) -> Self
1495    where
1496        I: IntoIterator<Item = HoverFxDefinition>,
1497    {
1498        self.with_effects(effects)
1499    }
1500
1501    pub fn with_default_effect(mut self, default_effect: impl AsRef<str>) -> Self {
1502        self.default_effect = hoverfx_id(default_effect);
1503        self
1504    }
1505
1506    pub fn default_effect(self, default_effect: impl AsRef<str>) -> Self {
1507        self.with_default_effect(default_effect)
1508    }
1509
1510    pub fn with_radius_px(mut self, radius_px: u16) -> Self {
1511        let range_tracks_radius = self.range_px == self.radius_px;
1512        self.radius_px = radius_px;
1513        if range_tracks_radius {
1514            self.range_px = radius_px;
1515        }
1516        self
1517    }
1518
1519    pub fn radius(self, radius_px: u16) -> Self {
1520        self.with_radius_px(radius_px)
1521    }
1522
1523    pub fn radius_px(self, radius_px: u16) -> Self {
1524        self.with_radius_px(radius_px)
1525    }
1526
1527    pub fn with_range_px(mut self, range_px: u16) -> Self {
1528        self.range_px = range_px;
1529        self
1530    }
1531
1532    pub fn range(self, range_px: u16) -> Self {
1533        self.with_range_px(range_px)
1534    }
1535
1536    pub fn range_px(self, range_px: u16) -> Self {
1537        self.with_range_px(range_px)
1538    }
1539
1540    pub fn activation_range(self, range_px: u16) -> Self {
1541        self.with_range_px(range_px)
1542    }
1543
1544    pub fn with_shape(mut self, shape: HoverFxShape) -> Self {
1545        self.shape = shape;
1546        self
1547    }
1548
1549    pub fn shape(self, shape: HoverFxShape) -> Self {
1550        self.with_shape(shape)
1551    }
1552
1553    pub fn with_falloff(mut self, falloff: HoverFxFalloff) -> Self {
1554        self.falloff = falloff;
1555        self
1556    }
1557
1558    pub fn falloff(self, falloff: HoverFxFalloff) -> Self {
1559        self.with_falloff(falloff)
1560    }
1561
1562    pub fn with_strength(mut self, strength: f32) -> Self {
1563        self.strength = strength;
1564        self
1565    }
1566
1567    pub fn strength(self, strength: f32) -> Self {
1568        self.with_strength(strength)
1569    }
1570
1571    pub fn with_smoothing(mut self, smoothing: f32) -> Self {
1572        self.smoothing = smoothing;
1573        self
1574    }
1575
1576    pub fn smooth(self, smoothing: f32) -> Self {
1577        self.with_smoothing(smoothing)
1578    }
1579
1580    pub fn with_max_active_elements(mut self, max_active_elements: u16) -> Self {
1581        self.max_active_elements = max_active_elements;
1582        self
1583    }
1584
1585    pub fn max_active(self, max_active_elements: u16) -> Self {
1586        self.with_max_active_elements(max_active_elements)
1587    }
1588
1589    pub fn with_renderer(mut self, renderer: HoverFxRenderer) -> Self {
1590        self.renderer = renderer;
1591        self
1592    }
1593
1594    pub fn renderer(self, renderer: HoverFxRenderer) -> Self {
1595        self.with_renderer(renderer)
1596    }
1597
1598    pub fn with_runtime_path(mut self, runtime_path: impl Into<String>) -> Self {
1599        self.runtime_path = runtime_path.into();
1600        self
1601    }
1602
1603    pub fn runtime(self, runtime_path: impl Into<String>) -> Self {
1604        self.with_runtime_path(runtime_path)
1605    }
1606
1607    pub fn with_worker_path(mut self, worker_path: impl Into<String>) -> Self {
1608        self.worker_path = worker_path.into();
1609        self
1610    }
1611
1612    pub fn worker_path(self, worker_path: impl Into<String>) -> Self {
1613        self.with_worker_path(worker_path)
1614    }
1615
1616    pub fn worker(mut self) -> Self {
1617        self.renderer = HoverFxRenderer::WorkerFirst;
1618        self
1619    }
1620
1621    pub fn no_worker(mut self) -> Self {
1622        self.renderer = HoverFxRenderer::CssOnly;
1623        self
1624    }
1625
1626    pub fn with_performance(mut self, performance: HoverFxPerformanceConfig) -> Self {
1627        self.performance = performance;
1628        self
1629    }
1630
1631    pub fn perf(self, performance: HoverFxPerformanceConfig) -> Self {
1632        self.with_performance(performance)
1633    }
1634
1635    pub fn with_lazy_local_layers(mut self, lazy_local_layers: bool) -> Self {
1636        self.performance.lazy_local_layers = lazy_local_layers;
1637        self
1638    }
1639
1640    pub fn lazy_layers_enabled(self, lazy_local_layers: bool) -> Self {
1641        self.with_lazy_local_layers(lazy_local_layers)
1642    }
1643
1644    pub fn lazy_layers(self) -> Self {
1645        self.with_lazy_local_layers(true)
1646    }
1647
1648    pub fn no_lazy_layers(self) -> Self {
1649        self.with_lazy_local_layers(false)
1650    }
1651
1652    pub fn with_worker_local_layers(mut self, worker_local_layers: bool) -> Self {
1653        self.performance.worker_local_layers = worker_local_layers;
1654        self
1655    }
1656
1657    pub fn worker_layers(self, worker_local_layers: bool) -> Self {
1658        self.with_worker_local_layers(worker_local_layers)
1659    }
1660
1661    pub fn with_dirty_rect_rendering(mut self, dirty_rect_rendering: bool) -> Self {
1662        self.performance.dirty_rect_rendering = dirty_rect_rendering;
1663        self
1664    }
1665
1666    pub fn dirty_rects(self, dirty_rect_rendering: bool) -> Self {
1667        self.with_dirty_rect_rendering(dirty_rect_rendering)
1668    }
1669
1670    pub fn with_shader_texture_cache(mut self, shader_texture_cache: bool) -> Self {
1671        self.performance.shader_texture_cache = shader_texture_cache;
1672        self
1673    }
1674
1675    pub fn texture_cache(self, shader_texture_cache: bool) -> Self {
1676        self.with_shader_texture_cache(shader_texture_cache)
1677    }
1678
1679    pub fn with_dpr_cap(mut self, dpr_cap: f32) -> Self {
1680        self.performance.dpr_cap = dpr_cap;
1681        self
1682    }
1683
1684    pub fn dpr(self, dpr_cap: f32) -> Self {
1685        self.with_dpr_cap(dpr_cap)
1686    }
1687
1688    pub fn with_idle_release_timeout_ms(mut self, idle_release_timeout_ms: u16) -> Self {
1689        self.performance.idle_release_timeout_ms = idle_release_timeout_ms;
1690        self
1691    }
1692
1693    pub fn idle_release_ms(self, idle_release_timeout_ms: u16) -> Self {
1694        self.with_idle_release_timeout_ms(idle_release_timeout_ms)
1695    }
1696
1697    #[cfg(feature = "viewtx-interop")]
1698    pub fn with_viewtx_motion_policy(
1699        mut self,
1700        policy: &dioxus_viewtx_core::ViewMotionPolicy,
1701    ) -> Self {
1702        self.performance = self.performance.with_viewtx_motion_policy(policy);
1703        self
1704    }
1705
1706    pub fn with_candidate_observer_margin_px(mut self, candidate_observer_margin_px: u16) -> Self {
1707        self.performance.candidate_observer_margin_px = Some(candidate_observer_margin_px);
1708        self
1709    }
1710
1711    pub fn observer_margin(self, candidate_observer_margin_px: u16) -> Self {
1712        self.with_candidate_observer_margin_px(candidate_observer_margin_px)
1713    }
1714
1715    pub fn validate(&self) -> HoverFxValidationReport {
1716        let mut report = HoverFxValidationReport::default();
1717
1718        validate_radius(self.radius_px, "radius_px", None, &mut report);
1719        validate_range(self.range_px, "range_px", None, &mut report);
1720        validate_strength(self.strength, "strength", None, &mut report);
1721        validate_smoothing(self.smoothing, "smoothing", None, &mut report);
1722        validate_performance(&self.performance, &mut report);
1723        if self.max_active_elements == 0
1724            || self.max_active_elements > MAX_HOVERFX_MAX_ACTIVE_ELEMENTS
1725        {
1726            report.push(HoverFxValidationIssue::error(
1727                HoverFxValidationCode::InvalidMaxActiveElements,
1728                "max_active_elements",
1729                format!(
1730                    "max active elements must be between 1 and {}",
1731                    MAX_HOVERFX_MAX_ACTIVE_ELEMENTS
1732                ),
1733            ));
1734        }
1735        if !is_hoverfx_id(&self.default_effect) {
1736            report.push(HoverFxValidationIssue::error(
1737                HoverFxValidationCode::InvalidDefaultEffectId,
1738                "default_effect",
1739                "default effect id must be a kebab-case hoverfx id",
1740            ));
1741        }
1742        if !self.registry.contains_effect(&self.default_effect) {
1743            report.push(HoverFxValidationIssue::error(
1744                HoverFxValidationCode::MissingDefaultEffect,
1745                "default_effect",
1746                format!("default effect `{}` is not registered", self.default_effect),
1747            ));
1748        }
1749        if self.renderer.uses_worker() {
1750            if self.runtime_path.trim().is_empty() {
1751                report.push(HoverFxValidationIssue::error(
1752                    HoverFxValidationCode::EmptyRuntimePath,
1753                    "runtime_path",
1754                    "worker-first hoverfx requires a runtime path",
1755                ));
1756            }
1757            if self.worker_path.trim().is_empty() {
1758                report.push(HoverFxValidationIssue::error(
1759                    HoverFxValidationCode::EmptyWorkerPath,
1760                    "worker_path",
1761                    "worker-first hoverfx requires a worker path",
1762                ));
1763            }
1764        }
1765        for effect in &self.registry.effects {
1766            validate_effect(effect, &mut report);
1767        }
1768
1769        report
1770    }
1771
1772    pub fn to_json(&self) -> serde_json::Result<String> {
1773        serde_json::to_string(self)
1774    }
1775
1776    pub fn to_stable_json(&self) -> serde_json::Result<String> {
1777        serde_json::to_string(self)
1778    }
1779
1780    pub fn to_preferred_json(
1781        &self,
1782        format: HoverFxSerializationFormat,
1783    ) -> serde_json::Result<String> {
1784        match format {
1785            HoverFxSerializationFormat::ReadableJson | HoverFxSerializationFormat::StableJson => {
1786                self.to_stable_json()
1787            }
1788            HoverFxSerializationFormat::CompactJson => self.to_compact_json(),
1789        }
1790    }
1791
1792    pub fn to_compact_json(&self) -> serde_json::Result<String> {
1793        let mut value = serde_json::to_value(self)?;
1794        let default = serde_json::to_value(HoverFxConfig::default())?;
1795        if let (Some(object), Some(defaults)) = (value.as_object_mut(), default.as_object()) {
1796            for key in [
1797                "registry",
1798                "defaultEffect",
1799                "radiusPx",
1800                "rangePx",
1801                "shape",
1802                "falloff",
1803                "strength",
1804                "smoothing",
1805                "maxActiveElements",
1806                "renderer",
1807                "runtimePath",
1808                "workerPath",
1809                "performance",
1810            ] {
1811                if object.get(key) == defaults.get(key) {
1812                    object.remove(key);
1813                }
1814            }
1815        }
1816        serde_json::to_string(&value)
1817    }
1818
1819    pub fn with_profile(mut self, profile: HoverFxPresetProfile) -> Self {
1820        profile.apply_to_config(&mut self);
1821        self
1822    }
1823
1824    pub fn cache_key(&self, route: Option<&str>) -> String {
1825        hoverfx_cache_key(self, route, None)
1826    }
1827
1828    pub fn manifest_fragment(&self, policy: &HoverFxRoutePolicy) -> HoverFxManifestFragment {
1829        hoverfx_manifest_fragment(self, policy)
1830    }
1831
1832    pub fn output_report(&self, policy: &HoverFxRoutePolicy) -> HoverFxOutputReport {
1833        hoverfx_output_report(self, policy)
1834    }
1835
1836    pub fn explain(&self, policy: &HoverFxRoutePolicy) -> HoverFxExplainReport {
1837        explain_hoverfx(self, policy)
1838    }
1839
1840    pub fn try_validated(self) -> Result<Self, HoverFxConfigError> {
1841        let report = self.validate();
1842        if report.is_valid() {
1843            Ok(self)
1844        } else {
1845            Err(HoverFxConfigError { report })
1846        }
1847    }
1848
1849    pub fn is_noop_for_route(&self, policy: &HoverFxRoutePolicy) -> bool {
1850        !policy.enabled || policy.emission == HoverFxRuntimeEmission::Disabled
1851    }
1852}
1853
1854#[derive(Debug, Clone, PartialEq, Eq)]
1855pub struct HoverFxConfigError {
1856    pub report: HoverFxValidationReport,
1857}
1858
1859impl fmt::Display for HoverFxConfigError {
1860    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1861        let count = self.report.errors().count();
1862        write!(f, "invalid HoverFX config ({count} error(s))")
1863    }
1864}
1865
1866impl std::error::Error for HoverFxConfigError {}
1867
1868#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
1869#[serde(rename_all = "kebab-case")]
1870pub enum HoverFxRuntimeEmission {
1871    Always,
1872    #[default]
1873    WhenUsed,
1874    CssOnly,
1875    Disabled,
1876}
1877
1878impl HoverFxRuntimeEmission {
1879    pub const fn as_attr(self) -> &'static str {
1880        match self {
1881            Self::Always => "always",
1882            Self::WhenUsed => "when-used",
1883            Self::CssOnly => "css-only",
1884            Self::Disabled => "disabled",
1885        }
1886    }
1887}
1888
1889#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
1890#[serde(rename_all = "kebab-case")]
1891pub enum HoverFxSerializationFormat {
1892    #[default]
1893    StableJson,
1894    ReadableJson,
1895    CompactJson,
1896}
1897
1898impl HoverFxSerializationFormat {
1899    pub const fn as_attr(self) -> &'static str {
1900        match self {
1901            Self::StableJson => "stable-json",
1902            Self::ReadableJson => "readable-json",
1903            Self::CompactJson => "compact-json",
1904        }
1905    }
1906}
1907
1908#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
1909#[serde(rename_all = "kebab-case")]
1910pub enum HoverFxDiagnosticVerbosity {
1911    Off,
1912    Summary,
1913    #[default]
1914    Detailed,
1915}
1916
1917#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
1918#[serde(rename_all = "kebab-case")]
1919pub enum HoverFxFallbackStrategy {
1920    #[default]
1921    StaticCss,
1922    NativePort,
1923    MainThread,
1924    DisableEffect,
1925}
1926
1927#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
1928#[serde(rename_all = "kebab-case")]
1929pub enum HoverFxPresetProfile {
1930    Conservative,
1931    #[default]
1932    Balanced,
1933    Aggressive,
1934}
1935
1936impl HoverFxPresetProfile {
1937    pub const fn as_attr(self) -> &'static str {
1938        match self {
1939            Self::Conservative => "conservative",
1940            Self::Balanced => "balanced",
1941            Self::Aggressive => "aggressive",
1942        }
1943    }
1944
1945    pub fn apply_to_config(self, config: &mut HoverFxConfig) {
1946        match self {
1947            Self::Conservative => {
1948                config.renderer = HoverFxRenderer::CssOnly;
1949                config.range_px = config.range_px.min(240);
1950                config.max_active_elements = config.max_active_elements.min(4);
1951                config.performance.lazy_local_layers = true;
1952                config.performance.worker_local_layers = false;
1953                config.performance.dirty_rect_rendering = true;
1954                config.performance.shader_texture_cache = true;
1955                config.performance.dpr_cap = config.performance.dpr_cap.min(1.5);
1956            }
1957            Self::Balanced => {
1958                config.renderer = HoverFxRenderer::WorkerFirst;
1959                config.performance.lazy_local_layers = true;
1960                config.performance.worker_local_layers = true;
1961                config.performance.dirty_rect_rendering = true;
1962                config.performance.shader_texture_cache = true;
1963            }
1964            Self::Aggressive => {
1965                config.renderer = HoverFxRenderer::WorkerFirst;
1966                config.range_px = config.range_px.max(config.radius_px);
1967                config.max_active_elements = config.max_active_elements.max(12);
1968                config.performance.lazy_local_layers = false;
1969                config.performance.worker_local_layers = true;
1970                config.performance.dirty_rect_rendering = true;
1971                config.performance.shader_texture_cache = true;
1972                config.performance.dpr_cap = MAX_HOVERFX_PERF_DPR_CAP.min(2.5);
1973            }
1974        }
1975    }
1976}
1977
1978#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1979#[serde(rename_all = "camelCase")]
1980pub struct HoverFxInteropPolicy {
1981    pub strata: bool,
1982    pub resume: bool,
1983    pub native_port: bool,
1984    pub workertown: bool,
1985    pub textfx: bool,
1986    pub theme: bool,
1987    pub viewtx: bool,
1988}
1989
1990impl Default for HoverFxInteropPolicy {
1991    fn default() -> Self {
1992        Self {
1993            strata: true,
1994            resume: true,
1995            native_port: true,
1996            workertown: true,
1997            textfx: true,
1998            theme: true,
1999            viewtx: true,
2000        }
2001    }
2002}
2003
2004#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
2005#[serde(rename_all = "camelCase")]
2006pub struct HoverFxOutputBudget {
2007    #[serde(default, skip_serializing_if = "Option::is_none")]
2008    pub max_config_bytes: Option<usize>,
2009    #[serde(default, skip_serializing_if = "Option::is_none")]
2010    pub max_runtime_bytes: Option<usize>,
2011    #[serde(default, skip_serializing_if = "Option::is_none")]
2012    pub max_style_bytes: Option<usize>,
2013    #[serde(default, skip_serializing_if = "Option::is_none")]
2014    pub max_effect_count: Option<usize>,
2015}
2016
2017impl HoverFxOutputBudget {
2018    pub fn new() -> Self {
2019        Self::default()
2020    }
2021
2022    pub fn config_bytes(mut self, max: usize) -> Self {
2023        self.max_config_bytes = Some(max);
2024        self
2025    }
2026
2027    pub fn runtime_bytes(mut self, max: usize) -> Self {
2028        self.max_runtime_bytes = Some(max);
2029        self
2030    }
2031
2032    pub fn style_bytes(mut self, max: usize) -> Self {
2033        self.max_style_bytes = Some(max);
2034        self
2035    }
2036
2037    pub fn effect_count(mut self, max: usize) -> Self {
2038        self.max_effect_count = Some(max);
2039        self
2040    }
2041}
2042
2043#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2044#[serde(rename_all = "camelCase")]
2045pub struct HoverFxRoutePolicy {
2046    #[serde(default, skip_serializing_if = "Option::is_none")]
2047    pub route: Option<String>,
2048    pub enabled: bool,
2049    pub profile: HoverFxPresetProfile,
2050    pub emission: HoverFxRuntimeEmission,
2051    pub serialization: HoverFxSerializationFormat,
2052    pub diagnostics: HoverFxDiagnosticVerbosity,
2053    pub fallback: HoverFxFallbackStrategy,
2054    #[serde(default)]
2055    pub interop: HoverFxInteropPolicy,
2056    #[serde(default)]
2057    pub budget: HoverFxOutputBudget,
2058    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
2059    pub labels: BTreeMap<String, String>,
2060    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2061    pub tags: Vec<String>,
2062}
2063
2064impl Default for HoverFxRoutePolicy {
2065    fn default() -> Self {
2066        Self {
2067            route: None,
2068            enabled: true,
2069            profile: HoverFxPresetProfile::Balanced,
2070            emission: HoverFxRuntimeEmission::WhenUsed,
2071            serialization: HoverFxSerializationFormat::StableJson,
2072            diagnostics: HoverFxDiagnosticVerbosity::Detailed,
2073            fallback: HoverFxFallbackStrategy::StaticCss,
2074            interop: HoverFxInteropPolicy::default(),
2075            budget: HoverFxOutputBudget::default(),
2076            labels: BTreeMap::new(),
2077            tags: Vec::new(),
2078        }
2079    }
2080}
2081
2082impl HoverFxRoutePolicy {
2083    pub fn new() -> Self {
2084        Self::default()
2085    }
2086
2087    pub fn route(mut self, route: impl Into<String>) -> Self {
2088        self.route = Some(route.into());
2089        self
2090    }
2091
2092    pub fn enabled(mut self, enabled: bool) -> Self {
2093        self.enabled = enabled;
2094        self
2095    }
2096
2097    pub fn profile(mut self, profile: HoverFxPresetProfile) -> Self {
2098        self.profile = profile;
2099        self
2100    }
2101
2102    pub fn emission(mut self, emission: HoverFxRuntimeEmission) -> Self {
2103        self.emission = emission;
2104        self
2105    }
2106
2107    pub fn serialization(mut self, serialization: HoverFxSerializationFormat) -> Self {
2108        self.serialization = serialization;
2109        self
2110    }
2111
2112    pub fn diagnostics(mut self, diagnostics: HoverFxDiagnosticVerbosity) -> Self {
2113        self.diagnostics = diagnostics;
2114        self
2115    }
2116
2117    pub fn fallback(mut self, fallback: HoverFxFallbackStrategy) -> Self {
2118        self.fallback = fallback;
2119        self
2120    }
2121
2122    pub fn budget(mut self, budget: HoverFxOutputBudget) -> Self {
2123        self.budget = budget;
2124        self
2125    }
2126
2127    pub fn label(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
2128        self.labels.insert(key.into(), value.into());
2129        self
2130    }
2131
2132    pub fn tag(mut self, tag: impl Into<String>) -> Self {
2133        let tag = tag.into();
2134        if !tag.is_empty() && !self.tags.contains(&tag) {
2135            self.tags.push(tag);
2136            self.tags.sort();
2137        }
2138        self
2139    }
2140}
2141
2142#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2143#[serde(rename_all = "camelCase")]
2144pub struct HoverFxManifestFragment {
2145    pub package: String,
2146    pub version: String,
2147    #[serde(default, skip_serializing_if = "Option::is_none")]
2148    pub route: Option<String>,
2149    pub enabled: bool,
2150    pub cache_key: String,
2151    pub default_effect: String,
2152    pub runtime_path: String,
2153    pub worker_path: String,
2154    pub profile: HoverFxPresetProfile,
2155    pub emission: HoverFxRuntimeEmission,
2156    pub fallback: HoverFxFallbackStrategy,
2157    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
2158    pub labels: BTreeMap<String, String>,
2159    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2160    pub tags: Vec<String>,
2161    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
2162    pub metrics: BTreeMap<String, u64>,
2163    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
2164    pub policies: BTreeMap<String, serde_json::Value>,
2165}
2166
2167#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2168#[serde(rename_all = "camelCase")]
2169pub struct HoverFxOutputViolation {
2170    pub field: String,
2171    pub actual: usize,
2172    pub budget: usize,
2173}
2174
2175#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2176#[serde(rename_all = "camelCase")]
2177pub struct HoverFxOutputReport {
2178    pub package: String,
2179    #[serde(default, skip_serializing_if = "Option::is_none")]
2180    pub route: Option<String>,
2181    pub cache_key: String,
2182    pub config_bytes: usize,
2183    pub runtime_bytes: usize,
2184    pub style_bytes: usize,
2185    pub effect_count: usize,
2186    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2187    pub violations: Vec<HoverFxOutputViolation>,
2188}
2189
2190impl HoverFxOutputReport {
2191    pub fn is_within_budget(&self) -> bool {
2192        self.violations.is_empty()
2193    }
2194}
2195
2196#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2197#[serde(rename_all = "camelCase")]
2198pub struct HoverFxExplainReport {
2199    pub package: String,
2200    #[serde(default, skip_serializing_if = "Option::is_none")]
2201    pub route: Option<String>,
2202    pub cache_key: String,
2203    pub runtime_decision: String,
2204    pub style_decision: String,
2205    pub fallback_decision: String,
2206    pub validation: HoverFxValidationReport,
2207    pub manifest: HoverFxManifestFragment,
2208    pub output: HoverFxOutputReport,
2209    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2210    pub notes: Vec<String>,
2211}
2212
2213#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2214#[serde(rename_all = "camelCase")]
2215pub struct HoverFxCompatibilityRow {
2216    pub target: String,
2217    pub support: String,
2218    pub runtime: String,
2219    pub fallback: String,
2220    pub notes: String,
2221}
2222
2223#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2224#[serde(rename_all = "camelCase")]
2225pub struct HoverFxCompatibilityMatrix {
2226    pub package: String,
2227    pub rows: Vec<HoverFxCompatibilityRow>,
2228}
2229
2230pub trait HoverFxManifestPolicyHook {
2231    fn apply(&self, fragment: HoverFxManifestFragment) -> Option<HoverFxManifestFragment>;
2232}
2233
2234pub fn apply_hoverfx_manifest_hook<H>(
2235    config: &HoverFxConfig,
2236    policy: &HoverFxRoutePolicy,
2237    hook: &H,
2238) -> Option<HoverFxManifestFragment>
2239where
2240    H: HoverFxManifestPolicyHook,
2241{
2242    hook.apply(hoverfx_manifest_fragment(config, policy))
2243}
2244
2245pub fn hoverfx_route_policy() -> HoverFxRoutePolicy {
2246    HoverFxRoutePolicy::new()
2247}
2248
2249pub fn hoverfx_output_budget() -> HoverFxOutputBudget {
2250    HoverFxOutputBudget::new()
2251}
2252
2253pub fn hoverfx_cache_key(
2254    config: &HoverFxConfig,
2255    route: Option<&str>,
2256    extra: Option<&str>,
2257) -> String {
2258    let json = config.to_stable_json().unwrap_or_default();
2259    stable_hash_hex([
2260        HOVERFX_PACKAGE_NAME,
2261        HOVERFX_PACKAGE_VERSION,
2262        route.unwrap_or("*"),
2263        extra.unwrap_or(""),
2264        json.as_str(),
2265    ])
2266}
2267
2268pub fn hoverfx_manifest_fragment(
2269    config: &HoverFxConfig,
2270    policy: &HoverFxRoutePolicy,
2271) -> HoverFxManifestFragment {
2272    let output = hoverfx_output_report(config, policy);
2273    let mut metrics = BTreeMap::new();
2274    metrics.insert("configBytes".to_string(), output.config_bytes as u64);
2275    metrics.insert("effectCount".to_string(), output.effect_count as u64);
2276    metrics.insert("runtimeBytes".to_string(), output.runtime_bytes as u64);
2277    metrics.insert("styleBytes".to_string(), output.style_bytes as u64);
2278
2279    let mut policies = BTreeMap::new();
2280    policies.insert(
2281        "interop".to_string(),
2282        serde_json::to_value(&policy.interop).unwrap_or(serde_json::Value::Null),
2283    );
2284    policies.insert(
2285        "route".to_string(),
2286        serde_json::json!({
2287            "enabled": policy.enabled,
2288            "profile": policy.profile,
2289            "emission": policy.emission,
2290            "serialization": policy.serialization,
2291            "fallback": policy.fallback,
2292        }),
2293    );
2294
2295    HoverFxManifestFragment {
2296        package: HOVERFX_PACKAGE_NAME.to_string(),
2297        version: HOVERFX_PACKAGE_VERSION.to_string(),
2298        route: policy.route.clone(),
2299        enabled: policy.enabled,
2300        cache_key: output.cache_key,
2301        default_effect: config.default_effect.clone(),
2302        runtime_path: config.runtime_path.clone(),
2303        worker_path: config.worker_path.clone(),
2304        profile: policy.profile,
2305        emission: policy.emission,
2306        fallback: policy.fallback,
2307        labels: policy.labels.clone(),
2308        tags: policy.tags.clone(),
2309        metrics,
2310        policies,
2311    }
2312}
2313
2314pub fn hoverfx_output_report(
2315    config: &HoverFxConfig,
2316    policy: &HoverFxRoutePolicy,
2317) -> HoverFxOutputReport {
2318    let config_json = config
2319        .to_preferred_json(policy.serialization)
2320        .unwrap_or_default();
2321    let runtime_bytes = if policy.enabled && policy.emission != HoverFxRuntimeEmission::Disabled {
2322        config.runtime_path.len() + config.worker_path.len()
2323    } else {
2324        0
2325    };
2326    let style_bytes = config
2327        .registry
2328        .effects
2329        .iter()
2330        .map(|effect| {
2331            effect
2332                .css_vars
2333                .iter()
2334                .map(|(k, v)| k.len() + v.len() + 2)
2335                .sum::<usize>()
2336        })
2337        .sum::<usize>();
2338    let effect_count = config.registry.effects.len();
2339    let mut violations = Vec::new();
2340    push_budget_violation(
2341        &mut violations,
2342        "configBytes",
2343        config_json.len(),
2344        policy.budget.max_config_bytes,
2345    );
2346    push_budget_violation(
2347        &mut violations,
2348        "runtimeBytes",
2349        runtime_bytes,
2350        policy.budget.max_runtime_bytes,
2351    );
2352    push_budget_violation(
2353        &mut violations,
2354        "styleBytes",
2355        style_bytes,
2356        policy.budget.max_style_bytes,
2357    );
2358    push_budget_violation(
2359        &mut violations,
2360        "effectCount",
2361        effect_count,
2362        policy.budget.max_effect_count,
2363    );
2364
2365    HoverFxOutputReport {
2366        package: HOVERFX_PACKAGE_NAME.to_string(),
2367        route: policy.route.clone(),
2368        cache_key: hoverfx_cache_key(
2369            config,
2370            policy.route.as_deref(),
2371            Some(policy.profile.as_attr()),
2372        ),
2373        config_bytes: config_json.len(),
2374        runtime_bytes,
2375        style_bytes,
2376        effect_count,
2377        violations,
2378    }
2379}
2380
2381pub fn explain_hoverfx(
2382    config: &HoverFxConfig,
2383    policy: &HoverFxRoutePolicy,
2384) -> HoverFxExplainReport {
2385    let validation = config.validate();
2386    let output = hoverfx_output_report(config, policy);
2387    let manifest = hoverfx_manifest_fragment(config, policy);
2388    let runtime_decision = if !policy.enabled {
2389        "route disabled HoverFX emission".to_string()
2390    } else if policy.emission == HoverFxRuntimeEmission::Disabled {
2391        "runtime emission disabled by route policy".to_string()
2392    } else if config.renderer.uses_worker() && policy.interop.workertown {
2393        "worker-first runtime emitted with WorkerTown-compatible hints".to_string()
2394    } else {
2395        "CSS/static fallback is sufficient for this route".to_string()
2396    };
2397    let style_decision = if output.style_bytes == 0 {
2398        "no custom effect CSS variables were emitted".to_string()
2399    } else {
2400        format!(
2401            "{} bytes of custom effect CSS variables are tracked",
2402            output.style_bytes
2403        )
2404    };
2405    let fallback_decision = format!("fallback strategy: {:?}", policy.fallback);
2406    let mut notes = Vec::new();
2407    if !validation.is_valid() {
2408        notes.push("validation errors must be resolved before SSR emission".to_string());
2409    }
2410    if !output.is_within_budget() {
2411        notes.push("one or more HoverFX output budgets were exceeded".to_string());
2412    }
2413    if policy.interop.textfx {
2414        notes.push(
2415            "TextFX interop attributes are advertised for text reveal and tooltips".to_string(),
2416        );
2417    }
2418    if policy.interop.theme {
2419        notes.push("theme tokens are preserved as CSS custom properties".to_string());
2420    }
2421
2422    HoverFxExplainReport {
2423        package: HOVERFX_PACKAGE_NAME.to_string(),
2424        route: policy.route.clone(),
2425        cache_key: output.cache_key.clone(),
2426        runtime_decision,
2427        style_decision,
2428        fallback_decision,
2429        validation,
2430        manifest,
2431        output,
2432        notes,
2433    }
2434}
2435
2436pub fn hoverfx_compatibility_matrix() -> HoverFxCompatibilityMatrix {
2437    HoverFxCompatibilityMatrix {
2438        package: HOVERFX_PACKAGE_NAME.to_string(),
2439        rows: vec![
2440            HoverFxCompatibilityRow {
2441                target: "web".to_string(),
2442                support: "full".to_string(),
2443                runtime: "worker-first module plus optional CSS fallback".to_string(),
2444                fallback: "static-css".to_string(),
2445                notes: "cursor proximity, TextFX tooltip interop, and theme tokens are supported"
2446                    .to_string(),
2447            },
2448            HoverFxCompatibilityRow {
2449                target: "server".to_string(),
2450                support: "manifest".to_string(),
2451                runtime: "SSR config/style emission with route gates".to_string(),
2452                fallback: "disable-effect".to_string(),
2453                notes: "Strata/resume consumers can use manifest fragments and cache keys"
2454                    .to_string(),
2455            },
2456            HoverFxCompatibilityRow {
2457                target: "native".to_string(),
2458                support: "adapter".to_string(),
2459                runtime: "native-port action hints".to_string(),
2460                fallback: "native-port".to_string(),
2461                notes: "effects degrade to static CSS and explicit native actions".to_string(),
2462            },
2463            HoverFxCompatibilityRow {
2464                target: "cli".to_string(),
2465                support: "report".to_string(),
2466                runtime: "no runtime required".to_string(),
2467                fallback: "stable-json".to_string(),
2468                notes: "output reports include deterministic cache keys and budget violations"
2469                    .to_string(),
2470            },
2471        ],
2472    }
2473}
2474
2475pub fn hoverfx_native_port_hints(
2476    config: &HoverFxConfig,
2477    policy: &HoverFxRoutePolicy,
2478) -> BTreeMap<String, String> {
2479    let mut hints = BTreeMap::new();
2480    hints.insert("package".to_string(), HOVERFX_PACKAGE_NAME.to_string());
2481    hints.insert("version".to_string(), HOVERFX_PACKAGE_VERSION.to_string());
2482    hints.insert(
2483        "cacheKey".to_string(),
2484        hoverfx_cache_key(config, policy.route.as_deref(), None),
2485    );
2486    hints.insert(
2487        "route".to_string(),
2488        policy.route.clone().unwrap_or_else(|| "*".to_string()),
2489    );
2490    hints.insert("runtime".to_string(), policy.emission.as_attr().to_string());
2491    hints.insert("profile".to_string(), policy.profile.as_attr().to_string());
2492    hints.insert("fallback".to_string(), format!("{:?}", policy.fallback));
2493    hints
2494}
2495
2496fn push_budget_violation(
2497    violations: &mut Vec<HoverFxOutputViolation>,
2498    field: &str,
2499    actual: usize,
2500    budget: Option<usize>,
2501) {
2502    if let Some(budget) = budget
2503        && actual > budget
2504    {
2505        violations.push(HoverFxOutputViolation {
2506            field: field.to_string(),
2507            actual,
2508            budget,
2509        });
2510    }
2511}
2512
2513fn stable_hash_hex<'a>(parts: impl IntoIterator<Item = &'a str>) -> String {
2514    let mut hash = 0xcbf29ce484222325u64;
2515    for part in parts {
2516        for byte in part.as_bytes() {
2517            hash ^= u64::from(*byte);
2518            hash = hash.wrapping_mul(0x100000001b3);
2519        }
2520        hash ^= 0xff;
2521        hash = hash.wrapping_mul(0x100000001b3);
2522    }
2523    format!("{hash:016x}")
2524}
2525
2526fn validate_performance(
2527    performance: &HoverFxPerformanceConfig,
2528    report: &mut HoverFxValidationReport,
2529) {
2530    if !performance.dpr_cap.is_finite()
2531        || !(MIN_HOVERFX_PERF_DPR_CAP..=MAX_HOVERFX_PERF_DPR_CAP).contains(&performance.dpr_cap)
2532    {
2533        push_numeric_issue(
2534            HoverFxValidationCode::InvalidPerformanceMetric,
2535            None,
2536            "performance.dpr_cap",
2537            format!(
2538                "performance dpr cap must be finite and between {} and {}",
2539                MIN_HOVERFX_PERF_DPR_CAP, MAX_HOVERFX_PERF_DPR_CAP
2540            ),
2541            report,
2542        );
2543    }
2544    if performance.idle_release_timeout_ms > MAX_HOVERFX_PERF_IDLE_RELEASE_TIMEOUT_MS {
2545        push_numeric_issue(
2546            HoverFxValidationCode::InvalidPerformanceMetric,
2547            None,
2548            "performance.idle_release_timeout_ms",
2549            format!(
2550                "idle release timeout must be no more than {}ms",
2551                MAX_HOVERFX_PERF_IDLE_RELEASE_TIMEOUT_MS
2552            ),
2553            report,
2554        );
2555    }
2556    if let Some(margin) = performance.candidate_observer_margin_px
2557        && margin > MAX_HOVERFX_PERF_CANDIDATE_OBSERVER_MARGIN_PX
2558    {
2559        push_numeric_issue(
2560            HoverFxValidationCode::InvalidPerformanceMetric,
2561            None,
2562            "performance.candidate_observer_margin_px",
2563            format!(
2564                "candidate observer margin must be no more than {}px",
2565                MAX_HOVERFX_PERF_CANDIDATE_OBSERVER_MARGIN_PX
2566            ),
2567            report,
2568        );
2569    }
2570}
2571
2572fn validate_effect(effect: &HoverFxDefinition, report: &mut HoverFxValidationReport) {
2573    if !is_hoverfx_id(&effect.id) {
2574        report.push(HoverFxValidationIssue::effect_error(
2575            HoverFxValidationCode::InvalidEffectId,
2576            effect.id.clone(),
2577            "id",
2578            "effect id must be a kebab-case hoverfx id",
2579        ));
2580    }
2581    if let Some(radius_px) = effect.radius_px {
2582        validate_radius(radius_px, "radius_px", Some(&effect.id), report);
2583    }
2584    if let Some(range_px) = effect.range_px {
2585        validate_range(range_px, "range_px", Some(&effect.id), report);
2586    }
2587    if let Some(strength) = effect.strength {
2588        validate_strength(strength, "strength", Some(&effect.id), report);
2589    }
2590    if let Some(smoothing) = effect.smoothing {
2591        validate_smoothing(smoothing, "smoothing", Some(&effect.id), report);
2592    }
2593    if let Some(custom_shape) = &effect.custom_shape
2594        && !is_safe_css_custom_value(custom_shape)
2595    {
2596        report.push(HoverFxValidationIssue::effect_error(
2597            HoverFxValidationCode::UnsafeCustomShapeValue,
2598            effect.id.clone(),
2599            "custom_shape",
2600            "custom shape must be a safe CSS value",
2601        ));
2602    }
2603    if let Some(text_reveal) = &effect.text_reveal {
2604        validate_text_reveal(text_reveal, &effect.id, report);
2605    }
2606    if let Some(sand) = &effect.sand {
2607        validate_sand(sand, &effect.id, report);
2608    }
2609    if let Some(tooltip) = &effect.tooltip {
2610        validate_tooltip(tooltip, &effect.id, report);
2611    }
2612    for (name, value) in &effect.css_vars {
2613        if !is_custom_property_name(name) {
2614            report.push(HoverFxValidationIssue::effect_error(
2615                HoverFxValidationCode::InvalidCssVariableName,
2616                effect.id.clone(),
2617                name.clone(),
2618                "hoverfx CSS variables must be CSS custom properties",
2619            ));
2620        }
2621        if !is_safe_css_custom_value(value) {
2622            report.push(HoverFxValidationIssue::effect_error(
2623                HoverFxValidationCode::UnsafeCssValue,
2624                effect.id.clone(),
2625                name.clone(),
2626                "hoverfx CSS variable values must not contain declarations, URLs, or scriptable protocols",
2627            ));
2628        }
2629    }
2630}
2631
2632fn validate_tooltip(
2633    tooltip: &HoverFxTooltipConfig,
2634    effect: &str,
2635    report: &mut HoverFxValidationReport,
2636) {
2637    if tooltip.text.chars().count() > MAX_HOVERFX_TOOLTIP_TEXT_CHARS
2638        || tooltip
2639            .text
2640            .chars()
2641            .any(|ch| ch == '\0' || ch.is_control() && ch != '\n' && ch != '\t')
2642    {
2643        report.push(HoverFxValidationIssue::effect_error(
2644            HoverFxValidationCode::InvalidTooltipText,
2645            effect,
2646            "tooltip.text",
2647            format!(
2648                "tooltip text must contain at most {} printable characters",
2649                MAX_HOVERFX_TOOLTIP_TEXT_CHARS
2650            ),
2651        ));
2652    }
2653    if let Some(key) = &tooltip.i18n_key
2654        && (key.trim().is_empty()
2655            || key.chars().count() > MAX_HOVERFX_TOOLTIP_TEXT_CHARS
2656            || key
2657                .chars()
2658                .any(|ch| ch == '\0' || ch.is_control() || ch == '<' || ch == '>'))
2659    {
2660        report.push(HoverFxValidationIssue::effect_error(
2661            HoverFxValidationCode::InvalidTooltipI18nKey,
2662            effect,
2663            "tooltip.i18n_key",
2664            format!(
2665                "tooltip i18n key must contain at most {} printable characters",
2666                MAX_HOVERFX_TOOLTIP_TEXT_CHARS
2667            ),
2668        ));
2669    }
2670    if !(MIN_HOVERFX_TOOLTIP_MAX_WIDTH_PX..=MAX_HOVERFX_TOOLTIP_MAX_WIDTH_PX)
2671        .contains(&tooltip.max_width_px)
2672        || tooltip.offset_px > MAX_HOVERFX_TOOLTIP_OFFSET_PX
2673        || tooltip.show_delay_ms > MAX_HOVERFX_TOOLTIP_DELAY_MS
2674        || tooltip.hide_delay_ms > MAX_HOVERFX_TOOLTIP_DELAY_MS
2675        || tooltip.duration_ms == 0
2676        || tooltip.duration_ms > MAX_HOVERFX_TOOLTIP_DURATION_MS
2677        || tooltip.speed_ms == 0
2678        || tooltip.speed_ms > MAX_HOVERFX_TOOLTIP_DURATION_MS
2679        || tooltip.stagger_ms > MAX_HOVERFX_TOOLTIP_DURATION_MS
2680        || !tooltip.box_opacity.is_finite()
2681        || !(MIN_HOVERFX_TOOLTIP_BOX_OPACITY..=MAX_HOVERFX_TOOLTIP_BOX_OPACITY)
2682            .contains(&tooltip.box_opacity)
2683    {
2684        report.push(HoverFxValidationIssue::effect_error(
2685            HoverFxValidationCode::InvalidTooltipMetric,
2686            effect,
2687            "tooltip",
2688            "tooltip offset, width, opacity, delay, and timing values must stay within supported ranges",
2689        ));
2690    }
2691    if !is_supported_textfx_effect(&tooltip.textfx_effect) {
2692        report.push(HoverFxValidationIssue::effect_error(
2693            HoverFxValidationCode::UnsupportedTextFxEffect,
2694            effect,
2695            "tooltip.textfx_effect",
2696            format!(
2697                "tooltip textfx effect must be one of: {}",
2698                SUPPORTED_HOVERFX_TEXTFX_EFFECTS.join(", ")
2699            ),
2700        ));
2701    }
2702}
2703
2704fn validate_sand(sand: &HoverFxSandConfig, effect: &str, report: &mut HoverFxValidationReport) {
2705    for (field, value, min, max) in [
2706        ("sand.grain_size_px", sand.grain_size_px, 0.2, 12.0),
2707        ("sand.grain_density", sand.grain_density, 0.05, 8.0),
2708        ("sand.shimmer_density", sand.shimmer_density, 0.0, 2.0),
2709        ("sand.shimmer_strength", sand.shimmer_strength, 0.0, 4.0),
2710        (
2711            "sand.shimmer_radius_px",
2712            sand.shimmer_radius_px,
2713            1.0,
2714            2_000.0,
2715        ),
2716        ("sand.specular_strength", sand.specular_strength, 0.0, 4.0),
2717        ("sand.roughness", sand.roughness, 0.0, 1.0),
2718    ] {
2719        if !value.is_finite() || value < min || value > max {
2720            report.push(HoverFxValidationIssue::effect_error(
2721                HoverFxValidationCode::InvalidSandMetric,
2722                effect,
2723                field,
2724                format!("sand metric must be finite and between {min} and {max}"),
2725            ));
2726        }
2727    }
2728    if !(MIN_HOVERFX_SAND_ANIMATION_SPEED_MS..=MAX_HOVERFX_SAND_ANIMATION_SPEED_MS)
2729        .contains(&sand.animation_speed_ms)
2730    {
2731        report.push(HoverFxValidationIssue::effect_error(
2732            HoverFxValidationCode::InvalidSandAnimationSpeed,
2733            effect,
2734            "sand.animation_speed_ms",
2735            format!(
2736                "sand animation speed must be between {}ms and {}ms",
2737                MIN_HOVERFX_SAND_ANIMATION_SPEED_MS, MAX_HOVERFX_SAND_ANIMATION_SPEED_MS
2738            ),
2739        ));
2740    }
2741    for (field, value) in [
2742        ("sand.color", sand.color.as_str()),
2743        ("sand.highlight_color", sand.highlight_color.as_str()),
2744    ] {
2745        if !is_safe_css_custom_value(value) {
2746            report.push(HoverFxValidationIssue::effect_error(
2747                HoverFxValidationCode::UnsafeSandCssValue,
2748                effect,
2749                field,
2750                "sand CSS values must not contain declarations, URLs, or scriptable protocols",
2751            ));
2752        }
2753    }
2754}
2755
2756fn validate_text_reveal(
2757    text_reveal: &HoverFxTextRevealConfig,
2758    effect: &str,
2759    report: &mut HoverFxValidationReport,
2760) {
2761    let charset_len = text_reveal.charset.chars().count();
2762    if charset_len == 0
2763        || charset_len > MAX_HOVERFX_TEXT_REVEAL_CHARSET_CHARS
2764        || text_reveal.charset.chars().any(char::is_control)
2765    {
2766        report.push(HoverFxValidationIssue::effect_error(
2767            HoverFxValidationCode::InvalidTextRevealCharset,
2768            effect,
2769            "text_reveal.charset",
2770            format!(
2771                "text reveal charset must contain 1 to {} printable characters",
2772                MAX_HOVERFX_TEXT_REVEAL_CHARSET_CHARS
2773            ),
2774        ));
2775    }
2776    if !(MIN_HOVERFX_TEXT_REVEAL_CYCLE_SPEED_MS..=MAX_HOVERFX_TEXT_REVEAL_CYCLE_SPEED_MS)
2777        .contains(&text_reveal.cycle_speed_ms)
2778    {
2779        report.push(HoverFxValidationIssue::effect_error(
2780            HoverFxValidationCode::InvalidTextRevealCycleSpeed,
2781            effect,
2782            "text_reveal.cycle_speed_ms",
2783            format!(
2784                "text reveal cycle speed must be between {}ms and {}ms",
2785                MIN_HOVERFX_TEXT_REVEAL_CYCLE_SPEED_MS, MAX_HOVERFX_TEXT_REVEAL_CYCLE_SPEED_MS
2786            ),
2787        ));
2788    }
2789    if !text_reveal.density.is_finite() || !(0.05..=8.0).contains(&text_reveal.density) {
2790        report.push(HoverFxValidationIssue::effect_error(
2791            HoverFxValidationCode::InvalidTextRevealMetric,
2792            effect,
2793            "text_reveal.density",
2794            "text reveal density must be finite and between 0.05 and 8.0",
2795        ));
2796    }
2797    if text_reveal.font_size_px == 0
2798        || text_reveal.font_size_px > MAX_HOVERFX_TEXT_REVEAL_FONT_SIZE_PX
2799        || text_reveal.gap_px > MAX_HOVERFX_TEXT_REVEAL_GAP_PX
2800    {
2801        report.push(HoverFxValidationIssue::effect_error(
2802            HoverFxValidationCode::InvalidTextRevealMetric,
2803            effect,
2804            "text_reveal.font_size_px",
2805            "text reveal font size and gap must stay within supported pixel ranges",
2806        ));
2807    }
2808    for (field, value) in [
2809        ("text_reveal.font_family", text_reveal.font_family.as_str()),
2810        ("text_reveal.color", text_reveal.color.as_str()),
2811    ] {
2812        if !is_safe_css_custom_value(value) {
2813            report.push(HoverFxValidationIssue::effect_error(
2814                HoverFxValidationCode::UnsafeTextRevealCssValue,
2815                effect,
2816                field,
2817                "text reveal CSS values must not contain declarations, URLs, or scriptable protocols",
2818            ));
2819        }
2820    }
2821    if !is_supported_textfx_effect(&text_reveal.textfx_effect) {
2822        report.push(HoverFxValidationIssue::effect_error(
2823            HoverFxValidationCode::UnsupportedTextFxEffect,
2824            effect,
2825            "text_reveal.textfx_effect",
2826            format!(
2827                "textfx effect must be one of: {}",
2828                SUPPORTED_HOVERFX_TEXTFX_EFFECTS.join(", ")
2829            ),
2830        ));
2831    }
2832}
2833
2834fn validate_radius(
2835    radius_px: u16,
2836    field: &str,
2837    effect: Option<&str>,
2838    report: &mut HoverFxValidationReport,
2839) {
2840    if !(MIN_HOVERFX_RADIUS_PX..=MAX_HOVERFX_RADIUS_PX).contains(&radius_px) {
2841        push_numeric_issue(
2842            HoverFxValidationCode::InvalidRadius,
2843            effect,
2844            field,
2845            format!(
2846                "radius must be between {}px and {}px",
2847                MIN_HOVERFX_RADIUS_PX, MAX_HOVERFX_RADIUS_PX
2848            ),
2849            report,
2850        );
2851    }
2852}
2853
2854fn validate_range(
2855    range_px: u16,
2856    field: &str,
2857    effect: Option<&str>,
2858    report: &mut HoverFxValidationReport,
2859) {
2860    if !(MIN_HOVERFX_RANGE_PX..=MAX_HOVERFX_RANGE_PX).contains(&range_px) {
2861        push_numeric_issue(
2862            HoverFxValidationCode::InvalidRange,
2863            effect,
2864            field,
2865            format!(
2866                "range must be between {}px and {}px",
2867                MIN_HOVERFX_RANGE_PX, MAX_HOVERFX_RANGE_PX
2868            ),
2869            report,
2870        );
2871    }
2872}
2873
2874fn validate_strength(
2875    strength: f32,
2876    field: &str,
2877    effect: Option<&str>,
2878    report: &mut HoverFxValidationReport,
2879) {
2880    if !strength.is_finite() || !(0.0..=MAX_HOVERFX_STRENGTH).contains(&strength) {
2881        push_numeric_issue(
2882            HoverFxValidationCode::InvalidStrength,
2883            effect,
2884            field,
2885            format!("strength must be finite and between 0.0 and {MAX_HOVERFX_STRENGTH}"),
2886            report,
2887        );
2888    }
2889}
2890
2891fn validate_smoothing(
2892    smoothing: f32,
2893    field: &str,
2894    effect: Option<&str>,
2895    report: &mut HoverFxValidationReport,
2896) {
2897    if !smoothing.is_finite() || !(0.0..=1.0).contains(&smoothing) {
2898        push_numeric_issue(
2899            HoverFxValidationCode::InvalidSmoothing,
2900            effect,
2901            field,
2902            "smoothing must be finite and between 0.0 and 1.0",
2903            report,
2904        );
2905    }
2906}
2907
2908fn push_numeric_issue(
2909    code: HoverFxValidationCode,
2910    effect: Option<&str>,
2911    field: &str,
2912    message: impl Into<String>,
2913    report: &mut HoverFxValidationReport,
2914) {
2915    let message = message.into();
2916    match effect {
2917        Some(effect) => report.push(HoverFxValidationIssue::effect_error(
2918            code,
2919            effect.to_string(),
2920            field,
2921            message,
2922        )),
2923        None => report.push(HoverFxValidationIssue::error(code, field, message)),
2924    }
2925}
2926
2927pub fn hoverfx_id(id: impl AsRef<str>) -> String {
2928    let mut normalized = String::with_capacity(id.as_ref().len());
2929    let mut previous_dash = false;
2930    for ch in id.as_ref().trim().chars() {
2931        if ch.is_ascii_alphanumeric() {
2932            normalized.push(ch.to_ascii_lowercase());
2933            previous_dash = false;
2934        } else if matches!(ch, '-' | '_' | ' ' | '.' | ':' | '/') && !previous_dash {
2935            normalized.push('-');
2936            previous_dash = true;
2937        }
2938    }
2939    while normalized.ends_with('-') {
2940        normalized.pop();
2941    }
2942    if normalized.is_empty() {
2943        "effect".to_string()
2944    } else {
2945        normalized
2946    }
2947}
2948
2949pub fn is_hoverfx_id(id: &str) -> bool {
2950    let bytes = id.as_bytes();
2951    if bytes.is_empty() || bytes.first() == Some(&b'-') || bytes.last() == Some(&b'-') {
2952        return false;
2953    }
2954    let mut previous_dash = false;
2955    for byte in bytes {
2956        let valid = byte.is_ascii_lowercase() || byte.is_ascii_digit() || *byte == b'-';
2957        if !valid {
2958            return false;
2959        }
2960        if *byte == b'-' {
2961            if previous_dash {
2962                return false;
2963            }
2964            previous_dash = true;
2965        } else {
2966            previous_dash = false;
2967        }
2968    }
2969    true
2970}
2971
2972pub fn is_custom_property_name(name: &str) -> bool {
2973    let Some(rest) = name.strip_prefix("--") else {
2974        return false;
2975    };
2976    !rest.is_empty()
2977        && rest
2978            .bytes()
2979            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))
2980}
2981
2982pub fn is_safe_css_custom_value(value: &str) -> bool {
2983    let value = value.trim();
2984    if value.is_empty() || value.len() > 512 {
2985        return false;
2986    }
2987    if value
2988        .chars()
2989        .any(|ch| ch.is_control() || matches!(ch, '{' | '}' | ';' | '<' | '>'))
2990    {
2991        return false;
2992    }
2993    let lower = value.to_ascii_lowercase();
2994    ![
2995        "url(",
2996        "expression(",
2997        "@import",
2998        "javascript:",
2999        "vbscript:",
3000        "data:",
3001        "</script",
3002    ]
3003    .iter()
3004    .any(|needle| lower.contains(needle))
3005}
3006
3007pub fn is_supported_textfx_effect(effect: &str) -> bool {
3008    let effect = hoverfx_id(effect);
3009    SUPPORTED_HOVERFX_TEXTFX_EFFECTS
3010        .iter()
3011        .any(|supported| *supported == effect)
3012}
3013
3014pub mod prelude {
3015    pub use crate::integration::*;
3016    pub use crate::{
3017        HoverCfg, HoverDef, HoverFxCompatibilityMatrix, HoverFxCompatibilityRow, HoverFxConfig,
3018        HoverFxDefinition, HoverFxDiagnosticVerbosity, HoverFxExplainReport,
3019        HoverFxFallbackStrategy, HoverFxFalloff, HoverFxInteropPolicy, HoverFxManifestFragment,
3020        HoverFxManifestPolicyHook, HoverFxOutputBudget, HoverFxOutputReport,
3021        HoverFxOutputViolation, HoverFxPerformanceConfig, HoverFxPreset, HoverFxPresetProfile,
3022        HoverFxRegistry, HoverFxRenderer, HoverFxRoutePolicy, HoverFxRuntimeEmission,
3023        HoverFxSerializationFormat, HoverFxShape, HoverFxTooltipConfig, HoverFxTooltipPlacement,
3024        HoverPerf, HoverReg, explain_hoverfx, hover_def, hover_fx, hoverfx, hoverfx_cache_key,
3025        hoverfx_compatibility_matrix, hoverfx_manifest_fragment, hoverfx_native_port_hints,
3026        hoverfx_output_budget, hoverfx_output_report, hoverfx_route_policy,
3027    };
3028}
3029
3030#[cfg(test)]
3031mod tests {
3032    use super::*;
3033    use serde_json::json;
3034
3035    #[test]
3036    fn defaults_are_worker_first_and_include_builtin_presets() {
3037        let config = HoverFxConfig::default();
3038        assert_eq!(config.renderer, HoverFxRenderer::WorkerFirst);
3039        assert_eq!(config.radius_px, DEFAULT_HOVERFX_RADIUS_PX);
3040        assert_eq!(config.range_px, DEFAULT_HOVERFX_RANGE_PX);
3041        assert_eq!(config.falloff, HoverFxFalloff::Smooth);
3042        assert_eq!(config.strength, DEFAULT_HOVERFX_STRENGTH);
3043        assert_eq!(config.smoothing, DEFAULT_HOVERFX_SMOOTHING);
3044        assert!(config.performance.lazy_local_layers);
3045        assert!(config.performance.worker_local_layers);
3046        assert!(config.performance.dirty_rect_rendering);
3047        assert!(config.performance.shader_texture_cache);
3048        assert_eq!(config.performance.dpr_cap, DEFAULT_HOVERFX_PERF_DPR_CAP);
3049        assert_eq!(
3050            config.performance.idle_release_timeout_ms,
3051            DEFAULT_HOVERFX_PERF_IDLE_RELEASE_TIMEOUT_MS
3052        );
3053        assert_eq!(config.performance.candidate_observer_margin_px, None);
3054        assert_eq!(
3055            config.max_active_elements,
3056            DEFAULT_HOVERFX_MAX_ACTIVE_ELEMENTS
3057        );
3058        for preset in HoverFxPreset::all() {
3059            assert!(config.registry.contains_effect(preset.as_attr()));
3060        }
3061        assert!(config.validate().is_valid());
3062    }
3063
3064    #[test]
3065    fn preset_helpers_and_kebab_case_serialization_match_attrs() {
3066        assert_eq!(HoverFxPreset::SoftGlow.as_attr(), "soft-glow");
3067        assert_eq!(HoverFxPreset::BorderTrace.label(), "Border trace");
3068        assert_eq!(
3069            serde_json::to_value(HoverFxPreset::ColorWash).unwrap(),
3070            json!("color-wash")
3071        );
3072        assert_eq!(
3073            serde_json::to_value(HoverFxShape::RoundedRect).unwrap(),
3074            json!("rounded-rect")
3075        );
3076        assert_eq!(
3077            serde_json::to_value(HoverFxRenderer::WorkerFirst).unwrap(),
3078            json!("worker-first")
3079        );
3080        assert_eq!(HoverFxPreset::BinaryReveal.as_attr(), "binary-reveal");
3081        assert_eq!(HoverFxPreset::BinaryReveal.label(), "Binary reveal");
3082        assert_eq!(HoverFxPreset::TextureReveal.as_attr(), "texture-reveal");
3083        assert_eq!(HoverFxPreset::TextureReveal.label(), "Texture reveal");
3084        assert_eq!(HoverFxPreset::Sand.as_attr(), "sand");
3085        assert_eq!(HoverFxPreset::Sand.label(), "Sand");
3086        assert_eq!(
3087            serde_json::to_value(HoverFxPreset::Sand).unwrap(),
3088            json!("sand")
3089        );
3090        assert_eq!(HoverFxTextContrastMode::Auto.as_attr(), "auto");
3091        assert_eq!(HoverFxTextContrastMode::Darken.as_attr(), "darken");
3092        assert_eq!(HoverFxTextContrastMode::Invert.as_attr(), "invert");
3093        assert_eq!(
3094            serde_json::to_value(HoverFxTextContrastMode::Invert).unwrap(),
3095            json!("invert")
3096        );
3097        assert_eq!(
3098            serde_json::to_value(HoverFxTextureRevealMode::StaticGrain).unwrap(),
3099            json!("static-grain")
3100        );
3101        assert_eq!(HoverFxPreset::Tooltip.as_attr(), "tooltip");
3102        assert_eq!(HoverFxPreset::all().len(), 9);
3103    }
3104
3105    #[test]
3106    fn builder_overrides_serialize_camel_case() {
3107        let config = HoverFxConfig::new()
3108            .with_default_effect("brand-wash")
3109            .with_radius_px(260)
3110            .with_range_px(340)
3111            .with_shape(HoverFxShape::Square)
3112            .with_falloff(HoverFxFalloff::Exponential)
3113            .with_strength(1.4)
3114            .with_smoothing(0.25)
3115            .with_max_active_elements(12)
3116            .with_renderer(HoverFxRenderer::CssOnly)
3117            .with_performance(
3118                HoverFxPerformanceConfig::default()
3119                    .with_lazy_local_layers(false)
3120                    .with_worker_local_layers(false)
3121                    .with_dirty_rect_rendering(false)
3122                    .with_shader_texture_cache(false)
3123                    .with_dpr_cap(1.5)
3124                    .with_idle_release_timeout_ms(2400)
3125                    .with_candidate_observer_margin_px(720),
3126            )
3127            .with_runtime_path("/assets/custom-hoverfx.js")
3128            .with_worker_path("/assets/custom-hoverfx-worker.js")
3129            .with_effect(
3130                HoverFxDefinition::new("brand-wash", "Brand wash")
3131                    .with_preset(HoverFxPreset::ColorWash)
3132                    .with_radius_px(300)
3133                    .with_range_px(420)
3134                    .with_text_contrast(HoverFxTextContrastMode::Auto)
3135                    .with_css_var("--dxh-color", "rgba(14,165,233,0.30)"),
3136            );
3137
3138        assert!(config.validate().is_valid());
3139        let json = serde_json::to_value(&config).unwrap();
3140        assert_eq!(json["defaultEffect"], "brand-wash");
3141        assert_eq!(json["radiusPx"], 260);
3142        assert_eq!(json["rangePx"], 340);
3143        assert_eq!(json["maxActiveElements"], 12);
3144        assert_eq!(json["renderer"], "css-only");
3145        assert_eq!(json["performance"]["lazyLocalLayers"], false);
3146        assert_eq!(json["performance"]["workerLocalLayers"], false);
3147        assert_eq!(json["performance"]["dirtyRectRendering"], false);
3148        assert_eq!(json["performance"]["shaderTextureCache"], false);
3149        assert_eq!(json["performance"]["dprCap"], 1.5);
3150        assert_eq!(json["performance"]["idleReleaseTimeoutMs"], 2400);
3151        assert_eq!(json["performance"]["candidateObserverMarginPx"], 720);
3152        assert_eq!(json["registry"]["effects"][9]["preset"], "color-wash");
3153        assert_eq!(json["registry"]["effects"][9]["rangePx"], 420);
3154        assert_eq!(json["registry"]["effects"][9]["textContrast"], "auto");
3155
3156        let tracked = HoverFxConfig::new().with_radius_px(320);
3157        assert_eq!(tracked.range_px, 320);
3158        let independent = HoverFxConfig::new().with_range_px(160).with_radius_px(320);
3159        assert_eq!(independent.radius_px, 320);
3160        assert_eq!(independent.range_px, 160);
3161    }
3162
3163    #[test]
3164    fn validation_reports_invalid_config_and_effect_values() {
3165        let mut config = HoverFxConfig::new()
3166            .with_default_effect("missing")
3167            .with_radius_px(0)
3168            .with_strength(f32::INFINITY)
3169            .with_smoothing(1.4)
3170            .with_max_active_elements(0)
3171            .with_dpr_cap(4.0)
3172            .with_runtime_path("")
3173            .with_worker_path("");
3174        config.registry.effects.push(HoverFxDefinition {
3175            id: "Bad Id".to_string(),
3176            label: "Bad".to_string(),
3177            preset: None,
3178            radius_px: Some(0),
3179            range_px: Some(MAX_HOVERFX_RANGE_PX + 1),
3180            shape: Some(HoverFxShape::Polygon),
3181            falloff: Some(HoverFxFalloff::Hard),
3182            strength: Some(-1.0),
3183            smoothing: Some(f32::NAN),
3184            custom_shape: Some("polygon(0 0, url(javascript:bad))".to_string()),
3185            text_reveal: Some(
3186                HoverFxTextRevealConfig::default()
3187                    .with_charset("")
3188                    .with_cycle_speed_ms(1)
3189                    .with_density(f32::NAN)
3190                    .with_font_size_px(0)
3191                    .with_color("url(javascript:bad)")
3192                    .with_textfx_effect("fade"),
3193            ),
3194            texture_reveal: None,
3195            sand: Some(
3196                HoverFxSandConfig::default()
3197                    .with_grain_size_px(0.0)
3198                    .with_shimmer_density(f32::NAN)
3199                    .with_shimmer_radius_px(0.0)
3200                    .with_animation_speed_ms(1)
3201                    .with_color("url(javascript:bad)"),
3202            ),
3203            tooltip: Some(
3204                HoverFxTooltipConfig::new("\0")
3205                    .i18n_key("\0")
3206                    .with_max_width_px(1)
3207                    .with_offset_px(5_000)
3208                    .with_box_opacity(f32::NAN)
3209                    .with_duration_ms(0)
3210                    .with_textfx_effect("fade"),
3211            ),
3212            text_contrast: Some(HoverFxTextContrastMode::Invert),
3213            css_vars: BTreeMap::from([
3214                ("color".to_string(), "red".to_string()),
3215                (
3216                    "--dxh-bg".to_string(),
3217                    "url(javascript:alert(1))".to_string(),
3218                ),
3219            ]),
3220        });
3221
3222        let report = config.validate();
3223        let codes: Vec<_> = report.errors().map(|issue| issue.code).collect();
3224        assert!(codes.contains(&HoverFxValidationCode::MissingDefaultEffect));
3225        assert!(codes.contains(&HoverFxValidationCode::InvalidEffectId));
3226        assert!(codes.contains(&HoverFxValidationCode::EmptyRuntimePath));
3227        assert!(codes.contains(&HoverFxValidationCode::EmptyWorkerPath));
3228        assert!(codes.contains(&HoverFxValidationCode::InvalidRadius));
3229        assert!(codes.contains(&HoverFxValidationCode::InvalidStrength));
3230        assert!(codes.contains(&HoverFxValidationCode::InvalidSmoothing));
3231        assert!(codes.contains(&HoverFxValidationCode::InvalidMaxActiveElements));
3232        assert!(codes.contains(&HoverFxValidationCode::InvalidCssVariableName));
3233        assert!(codes.contains(&HoverFxValidationCode::UnsafeCssValue));
3234        assert!(codes.contains(&HoverFxValidationCode::UnsafeCustomShapeValue));
3235        assert!(codes.contains(&HoverFxValidationCode::InvalidTextRevealCharset));
3236        assert!(codes.contains(&HoverFxValidationCode::InvalidTextRevealCycleSpeed));
3237        assert!(codes.contains(&HoverFxValidationCode::InvalidTextRevealMetric));
3238        assert!(codes.contains(&HoverFxValidationCode::UnsafeTextRevealCssValue));
3239        assert!(codes.contains(&HoverFxValidationCode::UnsupportedTextFxEffect));
3240        assert!(codes.contains(&HoverFxValidationCode::InvalidSandMetric));
3241        assert!(codes.contains(&HoverFxValidationCode::InvalidSandAnimationSpeed));
3242        assert!(codes.contains(&HoverFxValidationCode::UnsafeSandCssValue));
3243        assert!(codes.contains(&HoverFxValidationCode::InvalidTooltipMetric));
3244        assert!(codes.contains(&HoverFxValidationCode::InvalidTooltipText));
3245        assert!(codes.contains(&HoverFxValidationCode::InvalidTooltipI18nKey));
3246        assert!(codes.contains(&HoverFxValidationCode::InvalidPerformanceMetric));
3247    }
3248
3249    #[test]
3250    fn binary_reveal_defaults_and_textfx_bridge_serialize() {
3251        let definition = HoverFxDefinition::from_preset(HoverFxPreset::BinaryReveal);
3252        let text_reveal = definition.text_reveal.expect("binary reveal config");
3253        assert_eq!(definition.radius_px, Some(300));
3254        assert_eq!(definition.shape, Some(HoverFxShape::Circle));
3255        assert_eq!(definition.falloff, Some(HoverFxFalloff::Smooth));
3256        assert_eq!(definition.strength, Some(1.15));
3257        assert_eq!(text_reveal.charset, "01");
3258        assert_eq!(text_reveal.cycle_speed_ms, 220);
3259        assert_eq!(
3260            text_reveal.animation_source,
3261            HoverFxTextAnimationSource::Auto
3262        );
3263        assert_eq!(text_reveal.renderer, HoverFxTextRevealRenderer::GlyphAtlas);
3264        assert_eq!(text_reveal.textfx_effect, "scramble");
3265        assert_eq!(
3266            HoverFxTextRevealRenderer::GlyphAtlas.as_attr(),
3267            "glyph-atlas"
3268        );
3269        assert_eq!(
3270            HoverFxTextRevealRenderer::CanvasGrid.as_attr(),
3271            "canvas-grid"
3272        );
3273        assert!(is_supported_textfx_effect("gradient-shift"));
3274        assert!(!is_supported_textfx_effect("fade"));
3275
3276        let json = text_reveal.to_json().unwrap();
3277        assert!(json.contains(r#""animationSource":"auto""#));
3278        assert!(json.contains(r#""renderer":"glyph-atlas""#));
3279        assert!(
3280            HoverFxTextRevealConfig::default()
3281                .with_renderer(HoverFxTextRevealRenderer::CanvasGrid)
3282                .to_json()
3283                .unwrap()
3284                .contains(r#""renderer":"canvas-grid""#)
3285        );
3286        let textfx = text_reveal
3287            .to_textfx_config_json("binary-demo", "0101")
3288            .unwrap();
3289        assert!(textfx.contains(r#""effect":"scramble""#));
3290        assert!(textfx.contains(r#""charset":"01""#));
3291        assert!(textfx.contains(r#""speedMs":220"#));
3292    }
3293
3294    #[test]
3295    fn texture_reveal_defaults_and_builder_serialize() {
3296        let definition = HoverFxDefinition::from_preset(HoverFxPreset::TextureReveal);
3297        let texture_reveal = definition.texture_reveal.expect("texture reveal config");
3298        assert_eq!(definition.radius_px, Some(340));
3299        assert_eq!(definition.shape, Some(HoverFxShape::Circle));
3300        assert_eq!(definition.falloff, Some(HoverFxFalloff::Smooth));
3301        assert_eq!(definition.strength, Some(1.1));
3302        assert_eq!(texture_reveal.mode, HoverFxTextureRevealMode::Auto);
3303        assert_eq!(HoverFxTextureRevealMode::Halftone.as_attr(), "halftone");
3304        assert_eq!(texture_reveal.to_json().unwrap(), r#"{"mode":"auto"}"#);
3305        assert!(
3306            HoverFxTextureRevealConfig::default()
3307                .with_mode(HoverFxTextureRevealMode::StaticGrain)
3308                .to_json()
3309                .unwrap()
3310                .contains(r#""mode":"static-grain""#)
3311        );
3312    }
3313
3314    #[test]
3315    fn sand_defaults_and_builder_serialize() {
3316        let definition = HoverFxDefinition::from_preset(HoverFxPreset::Sand);
3317        let sand = definition.sand.expect("sand config");
3318        assert_eq!(definition.radius_px, Some(320));
3319        assert_eq!(definition.shape, Some(HoverFxShape::Circle));
3320        assert_eq!(definition.falloff, Some(HoverFxFalloff::Smooth));
3321        assert_eq!(definition.strength, Some(1.1));
3322        assert_eq!(sand.grain_size_px, DEFAULT_HOVERFX_SAND_GRAIN_SIZE_PX);
3323        assert_eq!(
3324            sand.animation_speed_ms,
3325            DEFAULT_HOVERFX_SAND_ANIMATION_SPEED_MS
3326        );
3327        assert_eq!(
3328            sand.shimmer_radius_px,
3329            DEFAULT_HOVERFX_SAND_SHIMMER_RADIUS_PX
3330        );
3331        assert_eq!(sand.color_source, HoverFxSandColorSource::Custom);
3332        assert!(definition.css_vars.contains_key("--dxh-sand-color"));
3333
3334        let custom = HoverFxSandConfig::default()
3335            .with_grain_size_px(1.6)
3336            .with_grain_density(1.4)
3337            .with_shimmer_density(0.22)
3338            .with_shimmer_strength(0.9)
3339            .with_shimmer_radius_px(280.0)
3340            .with_specular_strength(1.2)
3341            .with_roughness(0.36)
3342            .with_animation_speed_ms(720)
3343            .with_color_source(HoverFxSandColorSource::Element)
3344            .with_color("#c9a96a")
3345            .with_highlight_color("#fff2b8");
3346        let json = serde_json::to_value(&custom).unwrap();
3347        assert!((json["grainSizePx"].as_f64().unwrap() - 1.6).abs() < 0.001);
3348        assert_eq!(json["shimmerRadiusPx"], 280.0);
3349        assert_eq!(json["animationSpeedMs"], 720);
3350        assert_eq!(json["colorSource"], "element");
3351        assert_eq!(json["highlightColor"], "#fff2b8");
3352        assert!(
3353            HoverFxDefinition::new("custom-sand", "Custom sand")
3354                .with_preset(HoverFxPreset::Sand)
3355                .with_sand(custom)
3356                .with_radius_px(360)
3357                .with_strength(1.2)
3358                .with_shape(HoverFxShape::Circle)
3359                .with_falloff(HoverFxFalloff::Smooth)
3360                .preset
3361                .is_some()
3362        );
3363    }
3364
3365    #[test]
3366    fn tooltip_defaults_and_textfx_bridge_serialize() {
3367        let definition = HoverFxDefinition::from_preset(HoverFxPreset::Tooltip);
3368        let tooltip = definition.tooltip.expect("tooltip config");
3369        assert_eq!(definition.radius_px, Some(96));
3370        assert_eq!(definition.range_px, Some(0));
3371        assert_eq!(definition.shape, Some(HoverFxShape::RoundedRect));
3372        assert_eq!(definition.strength, Some(0.0));
3373        assert_eq!(tooltip.placement, HoverFxTooltipPlacement::Cursor);
3374        assert_eq!(tooltip.offset_px, DEFAULT_HOVERFX_TOOLTIP_OFFSET_PX);
3375        assert_eq!(tooltip.box_opacity, DEFAULT_HOVERFX_TOOLTIP_BOX_OPACITY);
3376        assert_eq!(tooltip.textfx_effect, "scramble");
3377        assert_eq!(HoverFxTooltipPlacement::Cursor.as_attr(), "cursor");
3378
3379        let custom = HoverFxTooltipConfig::new("Ship only the hovered hint")
3380            .key("hoverfx.tooltip.text")
3381            .cursor()
3382            .offset(18)
3383            .max_width(320)
3384            .opacity(0.72)
3385            .typewriter()
3386            .dur_ms(320)
3387            .speed_ms(24)
3388            .stagger_ms(10);
3389        let json = custom.to_json().unwrap();
3390        assert!(json.contains(r#""placement":"cursor""#));
3391        assert!(json.contains(r#""i18nKey":"hoverfx.tooltip.text""#));
3392        assert!(json.contains(r#""boxOpacity":0.72"#));
3393        assert!(json.contains(r#""textfxEffect":"typewriter""#));
3394        let old_json: HoverFxTooltipConfig =
3395            serde_json::from_str(r#"{"placement":"top","offsetPx":12,"maxWidthPx":280,"showDelayMs":0,"hideDelayMs":90,"durationMs":260,"speedMs":18,"staggerMs":8,"textfxEffect":"scramble","split":"chars"}"#)
3396                .unwrap();
3397        assert_eq!(old_json.box_opacity, DEFAULT_HOVERFX_TOOLTIP_BOX_OPACITY);
3398
3399        let textfx = custom
3400            .to_textfx_config_json("hoverfx-tip", "fallback")
3401            .unwrap();
3402        assert!(textfx.contains(r#""id":"hoverfx-tip""#));
3403        assert!(textfx.contains(r#""text":"Ship only the hovered hint""#));
3404        assert!(textfx.contains(r#""effect":"typewriter""#));
3405        assert!(textfx.contains(r#""durationMs":320"#));
3406    }
3407
3408    #[test]
3409    fn route_policy_manifest_and_budget_report_are_stable() {
3410        let config = HoverFxConfig::new()
3411            .with_profile(HoverFxPresetProfile::Conservative)
3412            .with_effect(HoverFxDefinition::from_preset(HoverFxPreset::Tooltip));
3413        let policy = hoverfx_route_policy()
3414            .route("/hoverfx")
3415            .profile(HoverFxPresetProfile::Conservative)
3416            .emission(HoverFxRuntimeEmission::WhenUsed)
3417            .serialization(HoverFxSerializationFormat::CompactJson)
3418            .budget(hoverfx_output_budget().config_bytes(8).effect_count(2))
3419            .label("owner", "visual-effects")
3420            .tag("hover")
3421            .tag("tooltip");
3422
3423        let manifest = config.manifest_fragment(&policy);
3424        let report = config.output_report(&policy);
3425        let hints = hoverfx_native_port_hints(&config, &policy);
3426
3427        assert_eq!(manifest.package, HOVERFX_PACKAGE_NAME);
3428        assert_eq!(manifest.route.as_deref(), Some("/hoverfx"));
3429        assert_eq!(manifest.profile, HoverFxPresetProfile::Conservative);
3430        assert_eq!(manifest.metrics["effectCount"], report.effect_count as u64);
3431        assert_eq!(manifest.labels["owner"], "visual-effects");
3432        assert_eq!(
3433            manifest.tags,
3434            vec!["hover".to_string(), "tooltip".to_string()]
3435        );
3436        assert_eq!(hints["route"], "/hoverfx");
3437        assert_eq!(hints["profile"], "conservative");
3438        assert!(
3439            report
3440                .violations
3441                .iter()
3442                .any(|violation| violation.field == "configBytes")
3443        );
3444        assert_eq!(
3445            config.cache_key(Some("/hoverfx")),
3446            config.cache_key(Some("/hoverfx"))
3447        );
3448    }
3449
3450    #[test]
3451    fn explain_report_and_hook_cover_interop_decisions() {
3452        struct DropDisabled;
3453
3454        impl HoverFxManifestPolicyHook for DropDisabled {
3455            fn apply(&self, fragment: HoverFxManifestFragment) -> Option<HoverFxManifestFragment> {
3456                fragment.enabled.then_some(fragment)
3457            }
3458        }
3459
3460        let config = HoverFxConfig::new()
3461            .with_effect(HoverFxDefinition::from_preset(HoverFxPreset::BinaryReveal));
3462        let enabled_policy = hoverfx_route_policy().route("/hoverfx").tag("textfx");
3463        let disabled_policy = hoverfx_route_policy()
3464            .route("/hoverfx/off")
3465            .enabled(false)
3466            .emission(HoverFxRuntimeEmission::Disabled);
3467        let explain = explain_hoverfx(&config, &enabled_policy);
3468        let matrix = hoverfx_compatibility_matrix();
3469
3470        assert!(explain.validation.is_valid());
3471        assert!(
3472            explain
3473                .notes
3474                .iter()
3475                .any(|note| note.contains("TextFX interop"))
3476        );
3477        assert!(matrix.rows.iter().any(|row| row.target == "native"));
3478        assert!(apply_hoverfx_manifest_hook(&config, &enabled_policy, &DropDisabled).is_some());
3479        assert!(apply_hoverfx_manifest_hook(&config, &disabled_policy, &DropDisabled).is_none());
3480        assert!(config.is_noop_for_route(&disabled_policy));
3481    }
3482
3483    #[test]
3484    fn hoverfx_id_sanitizes_to_kebab_case() {
3485        assert_eq!(hoverfx_id(" Soft Glow "), "soft-glow");
3486        assert_eq!(hoverfx_id("brand.fx/accent"), "brand-fx-accent");
3487        assert_eq!(hoverfx_id(""), "effect");
3488        assert!(is_hoverfx_id("border-trace"));
3489        assert!(!is_hoverfx_id("BorderTrace"));
3490        assert!(!is_hoverfx_id("-bad"));
3491        assert!(!is_hoverfx_id("bad--id"));
3492    }
3493
3494    #[test]
3495    fn css_safety_allows_values_but_rejects_declarations_and_urls() {
3496        assert!(is_custom_property_name("--dxh-color"));
3497        assert!(!is_custom_property_name("dxh-color"));
3498        assert!(is_safe_css_custom_value("rgba(14,165,233,0.30)"));
3499        assert!(is_safe_css_custom_value(
3500            "polygon(0 0, 100% 0, 80% 100%, 0 90%)"
3501        ));
3502        assert!(!is_safe_css_custom_value("red; background: blue"));
3503        assert!(!is_safe_css_custom_value("url(https://example.com/a.png)"));
3504        assert!(!is_safe_css_custom_value("javascript:alert(1)"));
3505        assert!(!is_safe_css_custom_value("<script>alert(1)</script>"));
3506    }
3507}