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}