1use std::borrow::Cow;
8use std::collections::{BTreeMap, BTreeSet};
9use std::time::Duration;
10
11use serde::{Deserialize, Serialize};
12
13pub const DEFAULT_MOTION_DURATION_MS: u32 = 520;
14pub const DEFAULT_MOTION_STAGGER_MS: u32 = 32;
15pub const DIOXUS_MOTION_CORE_VERSION: &str = env!("CARGO_PKG_VERSION");
16
17pub type Motion = MotionGraph;
18pub type MotionGrp = MotionGroup;
19pub type MotionKf = MotionKeyframe;
20
21pub fn motion(id: impl Into<String>) -> MotionGraph {
22 MotionGraph::new(id)
23}
24
25pub fn motion_group(id: impl Into<String>) -> MotionGroup {
26 MotionGroup::new(id)
27}
28
29pub fn keyframe(offset: f32) -> MotionKeyframe {
30 MotionKeyframe::new(offset)
31}
32
33pub trait DurationDx {
34 fn ms(self) -> Duration;
35 fn s(self) -> Duration;
36}
37
38macro_rules! impl_duration_dx_unsigned {
39 ($($ty:ty),* $(,)?) => {
40 $(
41 impl DurationDx for $ty {
42 fn ms(self) -> Duration {
43 Duration::from_millis(self as u64)
44 }
45
46 fn s(self) -> Duration {
47 Duration::from_secs(self as u64)
48 }
49 }
50 )*
51 };
52}
53
54macro_rules! impl_duration_dx_signed {
55 ($($ty:ty),* $(,)?) => {
56 $(
57 impl DurationDx for $ty {
58 fn ms(self) -> Duration {
59 Duration::from_millis(self.max(0) as u64)
60 }
61
62 fn s(self) -> Duration {
63 Duration::from_secs(self.max(0) as u64)
64 }
65 }
66 )*
67 };
68}
69
70impl_duration_dx_unsigned!(u8, u16, u32, u64, usize);
71impl_duration_dx_signed!(i8, i16, i32, i64, isize);
72
73pub fn duration_ms_u32(duration: Duration) -> u32 {
74 duration.as_millis().min(u128::from(u32::MAX)) as u32
75}
76
77pub fn duration_ms_u16(duration: Duration) -> u16 {
78 duration.as_millis().min(u128::from(u16::MAX)) as u16
79}
80
81#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
82#[serde(rename_all = "kebab-case")]
83pub enum MotionTrigger {
84 #[default]
85 Manual,
86 Load,
87 Visible,
88 AllVisible,
89 AnyVisible,
90 Hover,
91 Click,
92 ScrollProgress,
93 RouteEnter,
94 RouteExit,
95 StateChange,
96}
97
98impl MotionTrigger {
99 pub const fn as_attr(self) -> &'static str {
100 match self {
101 Self::Manual => "manual",
102 Self::Load => "load",
103 Self::Visible => "visible",
104 Self::AllVisible => "all-visible",
105 Self::AnyVisible => "any-visible",
106 Self::Hover => "hover",
107 Self::Click => "click",
108 Self::ScrollProgress => "scroll-progress",
109 Self::RouteEnter => "route-enter",
110 Self::RouteExit => "route-exit",
111 Self::StateChange => "state-change",
112 }
113 }
114}
115
116#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
117#[serde(rename_all = "kebab-case")]
118pub enum MotionPlayback {
119 #[default]
120 Once,
121 Replay,
122 Loop,
123 Yoyo,
124 Reverse,
125 Alternate,
126 Infinite,
127 Count(u16),
128}
129
130impl MotionPlayback {
131 pub const fn as_attr(self) -> &'static str {
132 match self {
133 Self::Once => "once",
134 Self::Replay => "replay",
135 Self::Loop => "loop",
136 Self::Yoyo => "yoyo",
137 Self::Reverse => "reverse",
138 Self::Alternate => "alternate",
139 Self::Infinite => "infinite",
140 Self::Count(_) => "count",
141 }
142 }
143}
144
145#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, Default)]
146#[serde(rename_all = "kebab-case")]
147pub enum MotionCurve {
148 Linear,
149 EaseIn,
150 #[default]
151 EaseOut,
152 EaseInOut,
153 Spring,
154 CubicBezier(f32, f32, f32, f32),
155}
156
157impl MotionCurve {
158 pub fn css_value(self) -> String {
159 match self {
160 Self::Linear => "linear".to_string(),
161 Self::EaseIn => "cubic-bezier(.42,0,1,1)".to_string(),
162 Self::EaseOut => "cubic-bezier(0,0,.2,1)".to_string(),
163 Self::EaseInOut => "cubic-bezier(.42,0,.58,1)".to_string(),
164 Self::Spring => "cubic-bezier(.18,.89,.32,1.18)".to_string(),
165 Self::CubicBezier(x1, y1, x2, y2) => {
166 format!("cubic-bezier({x1},{y1},{x2},{y2})")
167 }
168 }
169 }
170}
171
172#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
173#[serde(rename_all = "kebab-case")]
174pub enum MotionReducedMotionPolicy {
175 Initial,
176 #[default]
177 Final,
178 Static,
179 Animate,
180 FadeOnly,
181}
182
183impl MotionReducedMotionPolicy {
184 pub const fn as_attr(self) -> &'static str {
185 match self {
186 Self::Initial => "initial",
187 Self::Final => "final",
188 Self::Static => "static",
189 Self::Animate => "animate",
190 Self::FadeOnly => "fade-only",
191 }
192 }
193}
194
195#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
196#[serde(rename_all = "kebab-case")]
197pub enum MotionRenderLane {
198 #[default]
199 DomWaapi,
200 LayoutFlip,
201 ViewTransition,
202 WorkerCanvas2d,
203 WorkerTownRender,
204 Text,
205 TextFx,
206 MorphFramePlan,
207 MorphScene,
208}
209
210impl MotionRenderLane {
211 pub const fn as_attr(self) -> &'static str {
212 match self {
213 Self::DomWaapi => "dom-waapi",
214 Self::LayoutFlip => "layout-flip",
215 Self::ViewTransition => "view-transition",
216 Self::WorkerCanvas2d => "worker-canvas-2d",
217 Self::WorkerTownRender => "workertown-render",
218 Self::Text => "text",
219 Self::TextFx => "textfx",
220 Self::MorphFramePlan => "morph-frame-plan",
221 Self::MorphScene => "morph-scene",
222 }
223 }
224}
225
226#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
227#[serde(rename_all = "kebab-case")]
228pub enum MotionTrackKind {
229 #[default]
230 ElementKeyframes,
231 Text,
232 TextFx,
233 MorphConfig,
234 MorphScene,
235 MorphFramePlan,
236 RouteMorph,
237 Custom,
238}
239
240impl MotionTrackKind {
241 pub const fn as_attr(self) -> &'static str {
242 match self {
243 Self::ElementKeyframes => "element-keyframes",
244 Self::Text => "text",
245 Self::TextFx => "textfx",
246 Self::MorphConfig => "morph-config",
247 Self::MorphScene => "morph-scene",
248 Self::MorphFramePlan => "morph-frame-plan",
249 Self::RouteMorph => "route-morph",
250 Self::Custom => "custom",
251 }
252 }
253}
254
255#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
256#[serde(rename_all = "camelCase")]
257pub struct MotionTargetRef {
258 pub id: String,
259 pub package: String,
260 #[serde(skip_serializing_if = "Option::is_none")]
261 pub selector: Option<String>,
262}
263
264impl MotionTargetRef {
265 pub fn new(id: impl Into<String>, package: impl Into<String>) -> Self {
266 Self {
267 id: id.into(),
268 package: package.into(),
269 selector: None,
270 }
271 }
272
273 pub fn with_selector(mut self, selector: impl Into<String>) -> Self {
274 self.selector = Some(selector.into());
275 self
276 }
277}
278
279#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
280#[serde(rename_all = "camelCase")]
281pub struct MotionKeyframe {
282 pub offset: f32,
283 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
284 pub values: BTreeMap<String, serde_json::Value>,
285 #[serde(skip_serializing_if = "Option::is_none")]
286 pub curve: Option<MotionCurve>,
287}
288
289impl MotionKeyframe {
290 pub fn new(offset: f32) -> Self {
291 Self {
292 offset: clamp_unit(offset),
293 values: BTreeMap::new(),
294 curve: None,
295 }
296 }
297
298 pub fn with_value(mut self, key: impl Into<String>, value: serde_json::Value) -> Self {
299 self.values.insert(key.into(), value);
300 self
301 }
302
303 pub fn value(self, key: impl Into<String>, value: serde_json::Value) -> Self {
304 self.with_value(key, value)
305 }
306
307 pub fn with_curve(mut self, curve: MotionCurve) -> Self {
308 self.curve = Some(curve);
309 self
310 }
311
312 pub fn curve(self, curve: MotionCurve) -> Self {
313 self.with_curve(curve)
314 }
315}
316
317#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
318#[serde(rename_all = "camelCase")]
319pub struct MotionGroup {
320 pub id: String,
321 pub trigger: MotionTrigger,
322 pub duration_ms: u32,
323 pub delay_ms: u32,
324 pub stagger_ms: u32,
325 pub curve: MotionCurve,
326 pub playback: MotionPlayback,
327 pub reduced_motion: MotionReducedMotionPolicy,
328 #[serde(default, skip_serializing_if = "Vec::is_empty")]
329 pub render_lanes: Vec<MotionRenderLane>,
330 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
331 pub metadata: BTreeMap<String, serde_json::Value>,
332}
333
334impl MotionGroup {
335 pub fn new(id: impl Into<String>) -> Self {
336 Self {
337 id: id.into(),
338 trigger: MotionTrigger::Manual,
339 duration_ms: DEFAULT_MOTION_DURATION_MS,
340 delay_ms: 0,
341 stagger_ms: DEFAULT_MOTION_STAGGER_MS,
342 curve: MotionCurve::EaseOut,
343 playback: MotionPlayback::Once,
344 reduced_motion: MotionReducedMotionPolicy::Final,
345 render_lanes: Vec::new(),
346 metadata: BTreeMap::new(),
347 }
348 }
349
350 pub fn with_trigger(mut self, trigger: MotionTrigger) -> Self {
351 self.trigger = trigger;
352 self
353 }
354
355 pub fn trigger(self, trigger: MotionTrigger) -> Self {
356 self.with_trigger(trigger)
357 }
358
359 pub fn with_duration_ms(mut self, duration_ms: u32) -> Self {
360 self.duration_ms = duration_ms.max(1);
361 self
362 }
363
364 pub fn dur_ms(self, duration_ms: u32) -> Self {
365 self.with_duration_ms(duration_ms)
366 }
367
368 pub fn with_delay_ms(mut self, delay_ms: u32) -> Self {
369 self.delay_ms = delay_ms;
370 self
371 }
372
373 pub fn delay_ms(self, delay_ms: u32) -> Self {
374 self.with_delay_ms(delay_ms)
375 }
376
377 pub fn with_stagger_ms(mut self, stagger_ms: u32) -> Self {
378 self.stagger_ms = stagger_ms;
379 self
380 }
381
382 pub fn stagger_ms(self, stagger_ms: u32) -> Self {
383 self.with_stagger_ms(stagger_ms)
384 }
385
386 pub fn with_curve(mut self, curve: MotionCurve) -> Self {
387 self.curve = curve;
388 self
389 }
390
391 pub fn curve(self, curve: MotionCurve) -> Self {
392 self.with_curve(curve)
393 }
394
395 pub fn with_playback(mut self, playback: MotionPlayback) -> Self {
396 self.playback = playback;
397 self
398 }
399
400 pub fn playback(self, playback: MotionPlayback) -> Self {
401 self.with_playback(playback)
402 }
403
404 pub fn with_reduced_motion(mut self, policy: MotionReducedMotionPolicy) -> Self {
405 self.reduced_motion = policy;
406 self
407 }
408
409 pub fn reduced(self, policy: MotionReducedMotionPolicy) -> Self {
410 self.with_reduced_motion(policy)
411 }
412
413 pub fn with_render_lane(mut self, lane: MotionRenderLane) -> Self {
414 if !self.render_lanes.contains(&lane) {
415 self.render_lanes.push(lane);
416 }
417 self
418 }
419
420 pub fn lane(self, lane: MotionRenderLane) -> Self {
421 self.with_render_lane(lane)
422 }
423
424 pub fn with_metadata(mut self, key: impl Into<String>, value: serde_json::Value) -> Self {
425 self.metadata.insert(key.into(), value);
426 self
427 }
428
429 pub fn meta(self, key: impl Into<String>, value: serde_json::Value) -> Self {
430 self.with_metadata(key, value)
431 }
432}
433
434#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
435#[serde(rename_all = "camelCase")]
436pub struct MotionTrack {
437 pub id: String,
438 pub group: String,
439 pub kind: MotionTrackKind,
440 pub target: MotionTargetRef,
441 #[serde(skip_serializing_if = "Option::is_none")]
442 pub duration_ms: Option<u32>,
443 #[serde(skip_serializing_if = "Option::is_none")]
444 pub delay_ms: Option<u32>,
445 #[serde(skip_serializing_if = "Option::is_none")]
446 pub stagger_index: Option<u16>,
447 #[serde(default, skip_serializing_if = "Vec::is_empty")]
448 pub keyframes: Vec<MotionKeyframe>,
449 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
450 pub metadata: BTreeMap<String, serde_json::Value>,
451}
452
453impl MotionTrack {
454 pub fn new(
455 id: impl Into<String>,
456 group: impl Into<String>,
457 kind: MotionTrackKind,
458 target: MotionTargetRef,
459 ) -> Self {
460 Self {
461 id: id.into(),
462 group: group.into(),
463 kind,
464 target,
465 duration_ms: None,
466 delay_ms: None,
467 stagger_index: None,
468 keyframes: Vec::new(),
469 metadata: BTreeMap::new(),
470 }
471 }
472
473 pub fn with_duration_ms(mut self, duration_ms: u32) -> Self {
474 self.duration_ms = Some(duration_ms.max(1));
475 self
476 }
477
478 pub fn dur_ms(self, duration_ms: u32) -> Self {
479 self.with_duration_ms(duration_ms)
480 }
481
482 pub fn with_delay_ms(mut self, delay_ms: u32) -> Self {
483 self.delay_ms = Some(delay_ms);
484 self
485 }
486
487 pub fn delay_ms(self, delay_ms: u32) -> Self {
488 self.with_delay_ms(delay_ms)
489 }
490
491 pub fn with_stagger_index(mut self, index: u16) -> Self {
492 self.stagger_index = Some(index);
493 self
494 }
495
496 pub fn stagger(self, index: u16) -> Self {
497 self.with_stagger_index(index)
498 }
499
500 pub fn with_keyframes(mut self, keyframes: impl Into<Vec<MotionKeyframe>>) -> Self {
501 let mut keyframes = keyframes.into();
502 keyframes.sort_by(|a, b| a.offset.total_cmp(&b.offset));
503 self.keyframes = keyframes;
504 self
505 }
506
507 pub fn frames(self, keyframes: impl Into<Vec<MotionKeyframe>>) -> Self {
508 self.with_keyframes(keyframes)
509 }
510
511 pub fn with_metadata(mut self, key: impl Into<String>, value: serde_json::Value) -> Self {
512 self.metadata.insert(key.into(), value);
513 self
514 }
515
516 pub fn meta(self, key: impl Into<String>, value: serde_json::Value) -> Self {
517 self.with_metadata(key, value)
518 }
519}
520
521#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
522#[serde(rename_all = "camelCase")]
523pub struct MotionGraph {
524 pub id: String,
525 #[serde(default, skip_serializing_if = "Vec::is_empty")]
526 pub groups: Vec<MotionGroup>,
527 #[serde(default, skip_serializing_if = "Vec::is_empty")]
528 pub tracks: Vec<MotionTrack>,
529 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
530 pub metadata: BTreeMap<String, serde_json::Value>,
531}
532
533impl MotionGraph {
534 pub fn new(id: impl Into<String>) -> Self {
535 Self {
536 id: id.into(),
537 groups: Vec::new(),
538 tracks: Vec::new(),
539 metadata: BTreeMap::new(),
540 }
541 }
542
543 pub fn with_group(mut self, group: MotionGroup) -> Self {
544 self.groups.push(group);
545 self
546 }
547
548 pub fn group(self, group: MotionGroup) -> Self {
549 self.with_group(group)
550 }
551
552 pub fn with_track(mut self, track: MotionTrack) -> Self {
553 self.tracks.push(track);
554 self
555 }
556
557 pub fn track(self, track: MotionTrack) -> Self {
558 self.with_track(track)
559 }
560
561 pub fn with_metadata(mut self, key: impl Into<String>, value: serde_json::Value) -> Self {
562 self.metadata.insert(key.into(), value);
563 self
564 }
565
566 pub fn meta(self, key: impl Into<String>, value: serde_json::Value) -> Self {
567 self.with_metadata(key, value)
568 }
569
570 pub fn validate(&self) -> Result<(), MotionValidationError> {
571 validate_motion_graph(self)
572 }
573
574 pub fn to_json(&self) -> serde_json::Result<String> {
575 serde_json::to_string(self)
576 }
577}
578
579#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
580#[serde(rename_all = "kebab-case")]
581pub enum MotionIntegrationTarget {
582 Morph,
583 MotionSsr,
584 TextFx,
585 Theme,
586 Timeline,
587 TimelineCore,
588 ViewTx,
589 ViewTxCore,
590 ViewTxSsr,
591 StrataCore,
592 StrataSsr,
593 Resume,
594 ResumeSsr,
595 AssetBudget,
596 CssOpt,
597 HtmlOpt,
598 JsOpt,
599 NativePort,
600 NativePortCore,
601 NativePortCli,
602 WorkerTownCore,
603 WorkerTownSsr,
604 DxrCli,
605 Custom(String),
606}
607
608impl MotionIntegrationTarget {
609 pub fn package_name(&self) -> &str {
610 match self {
611 Self::Morph => "dioxus-morph-core",
612 Self::MotionSsr => "dioxus-motion-ssr",
613 Self::TextFx => "dioxus-textfx",
614 Self::Theme => "dioxus-theme",
615 Self::Timeline => "dioxus-timeline",
616 Self::TimelineCore => "dioxus-timeline-core",
617 Self::ViewTx => "dioxus-viewtx",
618 Self::ViewTxCore => "dioxus-viewtx-core",
619 Self::ViewTxSsr => "dioxus-viewtx-ssr",
620 Self::StrataCore => "dioxus-strata-core",
621 Self::StrataSsr => "dioxus-strata-ssr",
622 Self::Resume => "dioxus-resume",
623 Self::ResumeSsr => "dioxus-resume-ssr",
624 Self::AssetBudget => "dioxus-asset-budget",
625 Self::CssOpt => "dioxus-css-opt",
626 Self::HtmlOpt => "dioxus-html-opt",
627 Self::JsOpt => "dioxus-js-opt",
628 Self::NativePort => "dioxus-native-port",
629 Self::NativePortCore => "dioxus-native-port-core",
630 Self::NativePortCli => "dioxus-native-port-cli",
631 Self::WorkerTownCore => "dioxus-workertown-core",
632 Self::WorkerTownSsr => "dioxus-workertown-ssr",
633 Self::DxrCli => "dioxus-dxr-cli",
634 Self::Custom(package) => package.as_str(),
635 }
636 }
637}
638
639#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
640#[serde(rename_all = "kebab-case")]
641pub enum MotionPresetProfile {
642 Conservative,
643 #[default]
644 Balanced,
645 Aggressive,
646}
647
648impl MotionPresetProfile {
649 pub const fn as_attr(self) -> &'static str {
650 match self {
651 Self::Conservative => "conservative",
652 Self::Balanced => "balanced",
653 Self::Aggressive => "aggressive",
654 }
655 }
656
657 pub fn apply_to_group(self, group: MotionGroup) -> MotionGroup {
658 match self {
659 Self::Conservative => {
660 let duration_ms = group.duration_ms.min(DEFAULT_MOTION_DURATION_MS);
661 let stagger_ms = group.stagger_ms.min(DEFAULT_MOTION_STAGGER_MS);
662 group
663 .with_duration_ms(duration_ms)
664 .with_stagger_ms(stagger_ms)
665 .with_reduced_motion(MotionReducedMotionPolicy::Static)
666 }
667 Self::Balanced => group,
668 Self::Aggressive => {
669 let duration_ms = group.duration_ms.max(DEFAULT_MOTION_DURATION_MS + 120);
670 let stagger_ms = group.stagger_ms.max(DEFAULT_MOTION_STAGGER_MS);
671 group
672 .with_duration_ms(duration_ms)
673 .with_stagger_ms(stagger_ms)
674 }
675 }
676 }
677}
678
679#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
680#[serde(rename_all = "kebab-case")]
681pub enum MotionDiagnosticLevel {
682 Off,
683 Error,
684 Warn,
685 #[default]
686 Info,
687 Verbose,
688}
689
690#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
691#[serde(rename_all = "camelCase")]
692pub struct MotionDiagnosticVerbosity {
693 pub build: MotionDiagnosticLevel,
694 pub ssr: MotionDiagnosticLevel,
695 pub runtime: MotionDiagnosticLevel,
696}
697
698impl Default for MotionDiagnosticVerbosity {
699 fn default() -> Self {
700 Self {
701 build: MotionDiagnosticLevel::Info,
702 ssr: MotionDiagnosticLevel::Warn,
703 runtime: MotionDiagnosticLevel::Error,
704 }
705 }
706}
707
708#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
709#[serde(rename_all = "camelCase")]
710pub struct MotionDiagnostic {
711 pub code: String,
712 pub message: String,
713 pub remediation: String,
714 pub level: MotionDiagnosticLevel,
715 #[serde(skip_serializing_if = "Option::is_none")]
716 pub route: Option<String>,
717}
718
719impl MotionDiagnostic {
720 pub fn new(
721 code: impl Into<String>,
722 message: impl Into<String>,
723 remediation: impl Into<String>,
724 ) -> Self {
725 Self {
726 code: code.into(),
727 message: message.into(),
728 remediation: remediation.into(),
729 level: MotionDiagnosticLevel::Info,
730 route: None,
731 }
732 }
733
734 pub fn level(mut self, level: MotionDiagnosticLevel) -> Self {
735 self.level = level;
736 self
737 }
738
739 pub fn route(mut self, route: impl Into<String>) -> Self {
740 self.route = Some(route.into());
741 self
742 }
743}
744
745#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
746#[serde(rename_all = "camelCase")]
747pub struct MotionBudget {
748 pub max_bytes: u32,
749 pub max_records: u32,
750 pub max_duration_ms: u32,
751 #[serde(default)]
752 pub warn_only: bool,
753}
754
755impl Default for MotionBudget {
756 fn default() -> Self {
757 Self {
758 max_bytes: 24 * 1024,
759 max_records: 128,
760 max_duration_ms: DEFAULT_MOTION_DURATION_MS * 2,
761 warn_only: true,
762 }
763 }
764}
765
766#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
767#[serde(rename_all = "camelCase")]
768pub struct MotionRouteOverride {
769 pub route: String,
770 #[serde(skip_serializing_if = "Option::is_none")]
771 pub enabled: Option<bool>,
772 #[serde(skip_serializing_if = "Option::is_none")]
773 pub duration_ms: Option<u32>,
774 #[serde(skip_serializing_if = "Option::is_none")]
775 pub reduced_motion: Option<MotionReducedMotionPolicy>,
776 #[serde(skip_serializing_if = "Option::is_none")]
777 pub profile: Option<MotionPresetProfile>,
778 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
779 pub labels: BTreeMap<String, String>,
780 #[serde(default, skip_serializing_if = "Vec::is_empty")]
781 pub tags: Vec<String>,
782}
783
784impl MotionRouteOverride {
785 pub fn new(route: impl Into<String>) -> Self {
786 Self {
787 route: route.into(),
788 enabled: None,
789 duration_ms: None,
790 reduced_motion: None,
791 profile: None,
792 labels: BTreeMap::new(),
793 tags: Vec::new(),
794 }
795 }
796
797 pub fn enabled(mut self, enabled: bool) -> Self {
798 self.enabled = Some(enabled);
799 self
800 }
801
802 pub fn duration_ms(mut self, duration_ms: u32) -> Self {
803 self.duration_ms = Some(duration_ms.max(1));
804 self
805 }
806
807 pub fn reduced_motion(mut self, reduced_motion: MotionReducedMotionPolicy) -> Self {
808 self.reduced_motion = Some(reduced_motion);
809 self
810 }
811
812 pub fn profile(mut self, profile: MotionPresetProfile) -> Self {
813 self.profile = Some(profile);
814 self
815 }
816
817 pub fn label(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
818 self.labels.insert(key.into(), value.into());
819 self
820 }
821
822 pub fn tag(mut self, tag: impl Into<String>) -> Self {
823 self.tags.push(tag.into());
824 self
825 }
826}
827
828#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
829#[serde(rename_all = "camelCase")]
830pub struct MotionIntegrationPolicy {
831 pub package: String,
832 pub target: MotionIntegrationTarget,
833 pub profile: MotionPresetProfile,
834 pub route_enabled_by_default: bool,
835 pub reduced_motion: MotionReducedMotionPolicy,
836 pub base_path: String,
837 pub config_id: String,
838 pub asset_name: String,
839 pub budget: MotionBudget,
840 pub diagnostics: MotionDiagnosticVerbosity,
841 #[serde(default, skip_serializing_if = "Vec::is_empty")]
842 pub route_overrides: Vec<MotionRouteOverride>,
843 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
844 pub labels: BTreeMap<String, String>,
845 #[serde(default, skip_serializing_if = "Vec::is_empty")]
846 pub tags: Vec<String>,
847}
848
849impl MotionIntegrationPolicy {
850 pub fn new(target: MotionIntegrationTarget) -> Self {
851 Self {
852 package: "dioxus-motion-core".to_string(),
853 target,
854 profile: MotionPresetProfile::Balanced,
855 route_enabled_by_default: true,
856 reduced_motion: MotionReducedMotionPolicy::Final,
857 base_path: "/assets".to_string(),
858 config_id: "__DXMOTION_CONFIG__".to_string(),
859 asset_name: "dioxus-motion.json".to_string(),
860 budget: MotionBudget::default(),
861 diagnostics: MotionDiagnosticVerbosity::default(),
862 route_overrides: Vec::new(),
863 labels: BTreeMap::new(),
864 tags: Vec::new(),
865 }
866 }
867
868 pub fn target_package(&self) -> &str {
869 self.target.package_name()
870 }
871
872 pub fn profile(mut self, profile: MotionPresetProfile) -> Self {
873 self.profile = profile;
874 self
875 }
876
877 pub fn route_enabled_by_default(mut self, enabled: bool) -> Self {
878 self.route_enabled_by_default = enabled;
879 self
880 }
881
882 pub fn reduced_motion(mut self, reduced_motion: MotionReducedMotionPolicy) -> Self {
883 self.reduced_motion = reduced_motion;
884 self
885 }
886
887 pub fn base_path(mut self, base_path: impl Into<String>) -> Self {
888 self.base_path = base_path.into();
889 self
890 }
891
892 pub fn ids(mut self, config_id: impl Into<String>, asset_name: impl Into<String>) -> Self {
893 self.config_id = config_id.into();
894 self.asset_name = asset_name.into();
895 self
896 }
897
898 pub fn budget(mut self, budget: MotionBudget) -> Self {
899 self.budget = budget;
900 self
901 }
902
903 pub fn diagnostics(mut self, diagnostics: MotionDiagnosticVerbosity) -> Self {
904 self.diagnostics = diagnostics;
905 self
906 }
907
908 pub fn route_override(mut self, route_override: MotionRouteOverride) -> Self {
909 self.route_overrides.push(route_override);
910 self.route_overrides
911 .sort_by(|a, b| a.route.cmp(&b.route).then_with(|| a.tags.cmp(&b.tags)));
912 self
913 }
914
915 pub fn label(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
916 self.labels.insert(key.into(), value.into());
917 self
918 }
919
920 pub fn tag(mut self, tag: impl Into<String>) -> Self {
921 self.tags.push(tag.into());
922 self.tags.sort();
923 self.tags.dedup();
924 self
925 }
926
927 pub fn route_enabled(&self, route: &str) -> bool {
928 self.route_override_for(route)
929 .and_then(|route_override| route_override.enabled)
930 .unwrap_or(self.route_enabled_by_default)
931 }
932
933 pub fn route_override_for(&self, route: &str) -> Option<&MotionRouteOverride> {
934 self.route_overrides
935 .iter()
936 .find(|route_override| route_matches(&route_override.route, route))
937 }
938}
939
940#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
941#[serde(rename_all = "camelCase")]
942pub struct MotionManifestFragment {
943 pub package: String,
944 pub target: String,
945 pub cache_key: String,
946 pub config_ref: String,
947 pub route: String,
948 pub enabled: bool,
949 pub reduced_motion_aware: bool,
950 pub runtime_base_path: String,
951 pub asset_name: String,
952 #[serde(default, skip_serializing_if = "Vec::is_empty")]
953 pub capabilities: Vec<String>,
954 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
955 pub labels: BTreeMap<String, String>,
956 #[serde(default, skip_serializing_if = "Vec::is_empty")]
957 pub tags: Vec<String>,
958}
959
960impl MotionManifestFragment {
961 pub fn to_json(&self) -> serde_json::Result<String> {
962 serde_json::to_string(self)
963 }
964}
965
966#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
967#[serde(rename_all = "camelCase")]
968pub struct MotionExplainReport {
969 pub package: String,
970 pub target: String,
971 pub profile: MotionPresetProfile,
972 pub route: String,
973 pub enabled: bool,
974 pub cache_key: String,
975 pub reasons: Vec<String>,
976 pub diagnostics: Vec<MotionDiagnostic>,
977}
978
979impl MotionExplainReport {
980 pub fn to_text(&self) -> String {
981 let mut output = vec![
982 format!("package: {}", self.package),
983 format!("target: {}", self.target),
984 format!("route: {}", self.route),
985 format!("profile: {}", self.profile.as_attr()),
986 format!("enabled: {}", self.enabled),
987 format!("cache-key: {}", self.cache_key),
988 ];
989 output.extend(
990 self.reasons
991 .iter()
992 .map(|reason| format!("reason: {reason}")),
993 );
994 output.extend(
995 self.diagnostics
996 .iter()
997 .map(|diagnostic| format!("{}: {}", diagnostic.code, diagnostic.message)),
998 );
999 output.join("\n")
1000 }
1001}
1002
1003#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1004#[serde(rename_all = "camelCase")]
1005pub struct MotionCompatibilityRow {
1006 pub surface: String,
1007 pub supported: bool,
1008 pub behavior: String,
1009 pub validation: String,
1010}
1011
1012#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1013#[serde(rename_all = "camelCase")]
1014pub struct MotionOffloadPlan {
1015 pub package: String,
1016 pub route: String,
1017 pub worker_task: String,
1018 pub cache_key: String,
1019 pub serializable: bool,
1020 pub fallback: String,
1021 pub estimated_operations: u32,
1022 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1023 pub transfer_fields: Vec<String>,
1024}
1025
1026#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1027#[serde(rename_all = "camelCase")]
1028pub struct MotionTraceEvent {
1029 pub package: String,
1030 pub phase: String,
1031 pub route: String,
1032 pub cache_key: String,
1033 pub decision: String,
1034}
1035
1036#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1037#[serde(rename_all = "camelCase")]
1038pub struct MotionBatchRequest {
1039 pub deterministic_parallel: bool,
1040 pub route: String,
1041 pub graphs: Vec<MotionGraph>,
1042}
1043
1044impl MotionBatchRequest {
1045 pub fn new(route: impl Into<String>, graphs: impl Into<Vec<MotionGraph>>) -> Self {
1046 Self {
1047 deterministic_parallel: false,
1048 route: route.into(),
1049 graphs: graphs.into(),
1050 }
1051 }
1052
1053 pub fn deterministic_parallel(mut self, enabled: bool) -> Self {
1054 self.deterministic_parallel = enabled;
1055 self
1056 }
1057}
1058
1059#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1060#[serde(rename_all = "camelCase")]
1061pub struct MotionBaselineReport {
1062 pub package: String,
1063 pub output_bytes: usize,
1064 pub group_count: usize,
1065 pub track_count: usize,
1066 pub keyframe_count: usize,
1067 pub estimated_operations: usize,
1068}
1069
1070#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1071#[serde(rename_all = "kebab-case")]
1072pub enum MotionSerializationFormat {
1073 Json,
1074 PrettyJson,
1075 CompactJson,
1076}
1077
1078#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1079#[serde(rename_all = "camelCase")]
1080pub struct MotionDoctorReport {
1081 pub ok: bool,
1082 pub cache_key: String,
1083 pub diagnostics: Vec<MotionDiagnostic>,
1084}
1085
1086#[derive(Debug, Clone, PartialEq, Eq)]
1087pub enum MotionPolicyDecision<T> {
1088 Accept(T),
1089 Reject(MotionDiagnostic),
1090 Rewrite(T, MotionDiagnostic),
1091}
1092
1093pub trait MotionArtifactCache {
1094 fn get(&self, key: &str) -> Option<String>;
1095 fn insert(&mut self, key: String, value: String);
1096}
1097
1098#[derive(Debug, Clone, Default)]
1099pub struct MotionMemoryCache {
1100 entries: BTreeMap<String, String>,
1101}
1102
1103impl MotionArtifactCache for MotionMemoryCache {
1104 fn get(&self, key: &str) -> Option<String> {
1105 self.entries.get(key).cloned()
1106 }
1107
1108 fn insert(&mut self, key: String, value: String) {
1109 self.entries.insert(key, value);
1110 }
1111}
1112
1113#[derive(Debug, Clone, PartialEq, Eq)]
1114pub struct BorrowedMotionTargetRef<'a> {
1115 pub id: Cow<'a, str>,
1116 pub package: Cow<'a, str>,
1117 pub selector: Option<Cow<'a, str>>,
1118}
1119
1120impl<'a> BorrowedMotionTargetRef<'a> {
1121 pub fn new(id: impl Into<Cow<'a, str>>, package: impl Into<Cow<'a, str>>) -> Self {
1122 Self {
1123 id: id.into(),
1124 package: package.into(),
1125 selector: None,
1126 }
1127 }
1128
1129 pub fn selector(mut self, selector: impl Into<Cow<'a, str>>) -> Self {
1130 self.selector = Some(selector.into());
1131 self
1132 }
1133
1134 pub fn into_owned(self) -> MotionTargetRef {
1135 let mut target = MotionTargetRef::new(self.id.into_owned(), self.package.into_owned());
1136 if let Some(selector) = self.selector {
1137 target = target.with_selector(selector.into_owned());
1138 }
1139 target
1140 }
1141}
1142
1143pub fn motion_target_ref_borrowed<'a>(
1144 id: impl Into<Cow<'a, str>>,
1145 package: impl Into<Cow<'a, str>>,
1146) -> BorrowedMotionTargetRef<'a> {
1147 BorrowedMotionTargetRef::new(id, package)
1148}
1149
1150pub fn conservative_motion_group(id: impl Into<String>) -> MotionGroup {
1151 MotionPresetProfile::Conservative.apply_to_group(MotionGroup::new(id))
1152}
1153
1154pub fn balanced_motion_group(id: impl Into<String>) -> MotionGroup {
1155 MotionPresetProfile::Balanced.apply_to_group(MotionGroup::new(id))
1156}
1157
1158pub fn aggressive_motion_group(id: impl Into<String>) -> MotionGroup {
1159 MotionPresetProfile::Aggressive.apply_to_group(MotionGroup::new(id))
1160}
1161
1162pub fn motion_cache_key(
1163 graph: &MotionGraph,
1164 policy: &MotionIntegrationPolicy,
1165 route: Option<&str>,
1166) -> String {
1167 let graph_json = graph.to_json().unwrap_or_else(|_| format!("{graph:?}"));
1168 let policy_json = serde_json::to_string(policy).unwrap_or_else(|_| format!("{policy:?}"));
1169 stable_cache_key(&[
1170 DIOXUS_MOTION_CORE_VERSION,
1171 &graph_json,
1172 &policy_json,
1173 route.unwrap_or_default(),
1174 ])
1175}
1176
1177pub fn motion_manifest_fragment(
1178 graph: &MotionGraph,
1179 policy: &MotionIntegrationPolicy,
1180 route: impl AsRef<str>,
1181) -> MotionManifestFragment {
1182 let route = route.as_ref();
1183 let route_override = policy.route_override_for(route);
1184 let mut labels = policy.labels.clone();
1185 let mut tags = policy.tags.clone();
1186 if let Some(route_override) = route_override {
1187 labels.extend(route_override.labels.clone());
1188 tags.extend(route_override.tags.clone());
1189 }
1190 tags.sort();
1191 tags.dedup();
1192 let enabled = policy.route_enabled(route) && !is_default_motion_graph(graph);
1193 MotionManifestFragment {
1194 package: policy.package.clone(),
1195 target: policy.target_package().to_string(),
1196 cache_key: motion_cache_key(graph, policy, Some(route)),
1197 config_ref: policy.config_id.clone(),
1198 route: route.to_string(),
1199 enabled,
1200 reduced_motion_aware: true,
1201 runtime_base_path: policy.base_path.clone(),
1202 asset_name: policy.asset_name.clone(),
1203 capabilities: motion_capabilities(graph, policy),
1204 labels,
1205 tags,
1206 }
1207}
1208
1209pub fn explain_motion_integration(
1210 graph: &MotionGraph,
1211 policy: &MotionIntegrationPolicy,
1212 route: impl AsRef<str>,
1213) -> MotionExplainReport {
1214 let route = route.as_ref();
1215 let mut diagnostics = Vec::new();
1216 if let Err(error) = graph.validate() {
1217 diagnostics.push(
1218 MotionDiagnostic::new(
1219 "motion.validation",
1220 format!("{error:?}"),
1221 "ensure every track references an existing unique group",
1222 )
1223 .level(MotionDiagnosticLevel::Error)
1224 .route(route),
1225 );
1226 }
1227 if graph.groups.is_empty() && graph.tracks.is_empty() {
1228 diagnostics.push(
1229 MotionDiagnostic::new(
1230 "motion.empty",
1231 "motion graph has no groups or tracks",
1232 "skip runtime emission or add at least one group and track",
1233 )
1234 .level(MotionDiagnosticLevel::Warn)
1235 .route(route),
1236 );
1237 }
1238 let enabled = policy.route_enabled(route) && !is_default_motion_graph(graph);
1239 MotionExplainReport {
1240 package: policy.package.clone(),
1241 target: policy.target_package().to_string(),
1242 profile: policy
1243 .route_override_for(route)
1244 .and_then(|route_override| route_override.profile)
1245 .unwrap_or(policy.profile),
1246 route: route.to_string(),
1247 enabled,
1248 cache_key: motion_cache_key(graph, policy, Some(route)),
1249 reasons: vec![
1250 format!(
1251 "runtime emission is {} for route",
1252 if enabled { "enabled" } else { "disabled" }
1253 ),
1254 format!(
1255 "reduced motion policy resolves to {}",
1256 policy.reduced_motion.as_attr()
1257 ),
1258 "manifest labels and tags are stable-sorted for downstream filters".to_string(),
1259 ],
1260 diagnostics,
1261 }
1262}
1263
1264pub fn motion_compatibility_matrix() -> Vec<MotionCompatibilityRow> {
1265 vec![
1266 MotionCompatibilityRow {
1267 surface: "web".to_string(),
1268 supported: true,
1269 behavior: "WAAPI/CSS runtime lanes with reduced-motion gates".to_string(),
1270 validation: "cargo check --target wasm32-unknown-unknown --features web".to_string(),
1271 },
1272 MotionCompatibilityRow {
1273 surface: "server".to_string(),
1274 supported: true,
1275 behavior: "serializable manifest fragments and SSR runtime decisions".to_string(),
1276 validation: "cargo check --features server".to_string(),
1277 },
1278 MotionCompatibilityRow {
1279 surface: "native".to_string(),
1280 supported: true,
1281 behavior: "native-port hints carry runtime mode, labels, and fallback policy"
1282 .to_string(),
1283 validation: "cargo check --features native".to_string(),
1284 },
1285 MotionCompatibilityRow {
1286 surface: "cli".to_string(),
1287 supported: true,
1288 behavior: "stable cache keys and compact JSON reports for audit tools".to_string(),
1289 validation: "cargo test -p dioxus-motion-core".to_string(),
1290 },
1291 ]
1292}
1293
1294pub fn motion_workertown_offload_plan(
1295 graph: &MotionGraph,
1296 policy: &MotionIntegrationPolicy,
1297 route: impl AsRef<str>,
1298) -> MotionOffloadPlan {
1299 let route = route.as_ref();
1300 MotionOffloadPlan {
1301 package: policy.package.clone(),
1302 route: route.to_string(),
1303 worker_task: "motion.prepare-fragment".to_string(),
1304 cache_key: motion_cache_key(graph, policy, Some(route)),
1305 serializable: graph.to_json().is_ok(),
1306 fallback: "main-thread-shared-motion".to_string(),
1307 estimated_operations: motion_estimated_operations(graph) as u32,
1308 transfer_fields: vec![
1309 "groups".to_string(),
1310 "tracks".to_string(),
1311 "metadata".to_string(),
1312 ],
1313 }
1314}
1315
1316pub fn motion_doctor(graph: &MotionGraph, policy: &MotionIntegrationPolicy) -> MotionDoctorReport {
1317 let report = explain_motion_integration(graph, policy, "/");
1318 MotionDoctorReport {
1319 ok: report
1320 .diagnostics
1321 .iter()
1322 .all(|diagnostic| diagnostic.level != MotionDiagnosticLevel::Error),
1323 cache_key: report.cache_key,
1324 diagnostics: report.diagnostics,
1325 }
1326}
1327
1328pub fn motion_manifest_fragment_with_trace<F>(
1329 graph: &MotionGraph,
1330 policy: &MotionIntegrationPolicy,
1331 route: impl AsRef<str>,
1332 mut trace: F,
1333) -> MotionManifestFragment
1334where
1335 F: FnMut(&MotionTraceEvent),
1336{
1337 let route = route.as_ref();
1338 let fragment = motion_manifest_fragment(graph, policy, route);
1339 trace(&MotionTraceEvent {
1340 package: fragment.package.clone(),
1341 phase: "motion.manifest".to_string(),
1342 route: route.to_string(),
1343 cache_key: fragment.cache_key.clone(),
1344 decision: if fragment.enabled {
1345 "emit".to_string()
1346 } else {
1347 "skip".to_string()
1348 },
1349 });
1350 fragment
1351}
1352
1353pub fn motion_css_custom_properties(group: &MotionGroup) -> String {
1354 format!(
1355 "--dxmotion-duration:{}ms;--dxmotion-delay:{}ms;--dxmotion-stagger:{}ms;--dxmotion-ease:{};",
1356 group.duration_ms,
1357 group.delay_ms,
1358 group.stagger_ms,
1359 group.curve.css_value()
1360 )
1361}
1362
1363pub fn serialize_motion_fragment(
1364 fragment: &MotionManifestFragment,
1365 format: MotionSerializationFormat,
1366) -> serde_json::Result<String> {
1367 match format {
1368 MotionSerializationFormat::Json | MotionSerializationFormat::CompactJson => {
1369 serde_json::to_string(fragment)
1370 }
1371 MotionSerializationFormat::PrettyJson => serde_json::to_string_pretty(fragment),
1372 }
1373}
1374
1375pub fn apply_motion_policy_hook<F>(
1376 graph: MotionGraph,
1377 hook: F,
1378) -> Result<MotionGraph, MotionDiagnostic>
1379where
1380 F: FnOnce(MotionGraph) -> MotionPolicyDecision<MotionGraph>,
1381{
1382 match hook(graph) {
1383 MotionPolicyDecision::Accept(graph) | MotionPolicyDecision::Rewrite(graph, _) => Ok(graph),
1384 MotionPolicyDecision::Reject(diagnostic) => Err(diagnostic),
1385 }
1386}
1387
1388pub fn batch_motion_manifest(
1389 graphs: &[MotionGraph],
1390 policy: &MotionIntegrationPolicy,
1391 route: impl AsRef<str>,
1392) -> Vec<MotionManifestFragment> {
1393 let route = route.as_ref();
1394 let mut fragments = graphs
1395 .iter()
1396 .map(|graph| motion_manifest_fragment(graph, policy, route))
1397 .collect::<Vec<_>>();
1398 fragments.sort_by(|a, b| a.cache_key.cmp(&b.cache_key));
1399 fragments
1400}
1401
1402pub fn process_motion_batch(
1403 request: &MotionBatchRequest,
1404 policy: &MotionIntegrationPolicy,
1405) -> Vec<MotionManifestFragment> {
1406 let mut fragments = batch_motion_manifest(&request.graphs, policy, &request.route);
1407 if request.deterministic_parallel {
1408 fragments.sort_by(|a, b| {
1409 a.cache_key
1410 .cmp(&b.cache_key)
1411 .then_with(|| a.route.cmp(&b.route))
1412 });
1413 }
1414 fragments
1415}
1416
1417pub fn motion_baseline_report(graph: &MotionGraph) -> MotionBaselineReport {
1418 let output_bytes = graph.to_json().map(|json| json.len()).unwrap_or_default();
1419 MotionBaselineReport {
1420 package: "dioxus-motion-core".to_string(),
1421 output_bytes,
1422 group_count: graph.groups.len(),
1423 track_count: graph.tracks.len(),
1424 keyframe_count: graph
1425 .tracks
1426 .iter()
1427 .map(|track| track.keyframes.len())
1428 .sum::<usize>(),
1429 estimated_operations: motion_estimated_operations(graph),
1430 }
1431}
1432
1433pub fn compact_motion_dictionary(graph: &MotionGraph) -> BTreeMap<String, u16> {
1434 let mut values = BTreeSet::new();
1435 values.insert(graph.id.clone());
1436 for group in &graph.groups {
1437 values.insert(group.id.clone());
1438 values.insert(group.trigger.as_attr().to_string());
1439 values.insert(group.playback.as_attr().to_string());
1440 values.insert(group.reduced_motion.as_attr().to_string());
1441 for lane in &group.render_lanes {
1442 values.insert(lane.as_attr().to_string());
1443 }
1444 }
1445 for track in &graph.tracks {
1446 values.insert(track.id.clone());
1447 values.insert(track.group.clone());
1448 values.insert(track.kind.as_attr().to_string());
1449 values.insert(track.target.id.clone());
1450 values.insert(track.target.package.clone());
1451 }
1452 values
1453 .into_iter()
1454 .enumerate()
1455 .map(|(index, value)| (value, index.min(u16::MAX as usize) as u16))
1456 .collect()
1457}
1458
1459fn is_default_motion_graph(graph: &MotionGraph) -> bool {
1460 graph.groups.is_empty() && graph.tracks.is_empty()
1461}
1462
1463fn motion_capabilities(graph: &MotionGraph, policy: &MotionIntegrationPolicy) -> Vec<String> {
1464 let mut capabilities = BTreeSet::new();
1465 capabilities.insert("motion.schedule".to_string());
1466 capabilities.insert("motion.reduced-motion".to_string());
1467 capabilities.insert(format!("motion.target.{}", policy.target_package()));
1468 for group in &graph.groups {
1469 for lane in &group.render_lanes {
1470 capabilities.insert(format!("motion.lane.{}", lane.as_attr()));
1471 }
1472 }
1473 capabilities.into_iter().collect()
1474}
1475
1476fn motion_estimated_operations(graph: &MotionGraph) -> usize {
1477 graph.groups.len()
1478 + graph.tracks.len()
1479 + graph
1480 .tracks
1481 .iter()
1482 .map(|track| track.keyframes.len().max(1))
1483 .sum::<usize>()
1484}
1485
1486fn route_matches(pattern: &str, route: &str) -> bool {
1487 if pattern == route || pattern == "*" {
1488 return true;
1489 }
1490 pattern
1491 .strip_suffix('*')
1492 .is_some_and(|prefix| route.starts_with(prefix))
1493}
1494
1495fn stable_cache_key(parts: &[&str]) -> String {
1496 let mut hash = 0xcbf29ce484222325u64;
1497 for part in parts {
1498 for byte in part.as_bytes() {
1499 hash ^= u64::from(*byte);
1500 hash = hash.wrapping_mul(0x100000001b3);
1501 }
1502 hash ^= 0xff;
1503 hash = hash.wrapping_mul(0x100000001b3);
1504 }
1505 format!("motion:{hash:016x}")
1506}
1507
1508pub mod prelude {
1509 pub use crate::{
1510 BorrowedMotionTargetRef, DurationDx, Motion, MotionArtifactCache, MotionBaselineReport,
1511 MotionBatchRequest, MotionBudget, MotionCompatibilityRow, MotionCurve, MotionDiagnostic,
1512 MotionDiagnosticLevel, MotionDiagnosticVerbosity, MotionDoctorReport, MotionExplainReport,
1513 MotionGraph, MotionGroup, MotionGrp, MotionIntegrationPolicy, MotionIntegrationTarget,
1514 MotionKeyframe, MotionKf, MotionManifestFragment, MotionMemoryCache, MotionOffloadPlan,
1515 MotionPlayback, MotionPolicyDecision, MotionPresetProfile, MotionReducedMotionPolicy,
1516 MotionRenderLane, MotionRouteOverride, MotionSerializationFormat, MotionTargetRef,
1517 MotionTraceEvent, MotionTrack, MotionTrackKind, MotionTrigger, aggressive_motion_group,
1518 apply_motion_policy_hook, balanced_motion_group, batch_motion_manifest,
1519 compact_motion_dictionary, conservative_motion_group, duration_ms_u16, duration_ms_u32,
1520 explain_motion_integration, keyframe, motion, motion_baseline_report, motion_cache_key,
1521 motion_compatibility_matrix, motion_css_custom_properties, motion_doctor, motion_group,
1522 motion_manifest_fragment, motion_manifest_fragment_with_trace, motion_target_ref_borrowed,
1523 motion_workertown_offload_plan, process_motion_batch, serialize_motion_fragment,
1524 };
1525}
1526
1527pub mod dx {
1528 pub use crate::{DurationDx, duration_ms_u16, duration_ms_u32};
1529}
1530
1531#[derive(Debug, Clone, PartialEq, Eq)]
1532pub enum MotionValidationError {
1533 DuplicateGroupId(String),
1534 DuplicateTrackId(String),
1535 MissingTrackGroup { track: String, group: String },
1536}
1537
1538pub fn validate_motion_graph(graph: &MotionGraph) -> Result<(), MotionValidationError> {
1539 let mut group_ids = BTreeSet::new();
1540 for group in &graph.groups {
1541 if !group_ids.insert(group.id.clone()) {
1542 return Err(MotionValidationError::DuplicateGroupId(group.id.clone()));
1543 }
1544 }
1545
1546 let mut track_ids = BTreeSet::new();
1547 for track in &graph.tracks {
1548 if !track_ids.insert(track.id.clone()) {
1549 return Err(MotionValidationError::DuplicateTrackId(track.id.clone()));
1550 }
1551 if !group_ids.contains(&track.group) {
1552 return Err(MotionValidationError::MissingTrackGroup {
1553 track: track.id.clone(),
1554 group: track.group.clone(),
1555 });
1556 }
1557 }
1558
1559 Ok(())
1560}
1561
1562fn clamp_unit(value: f32) -> f32 {
1563 if value.is_nan() {
1564 0.0
1565 } else {
1566 value.clamp(0.0, 1.0)
1567 }
1568}
1569
1570#[cfg(test)]
1571mod tests {
1572 use super::*;
1573
1574 #[test]
1575 fn validates_deterministic_group_and_track_ids() {
1576 let graph = MotionGraph::new("demo")
1577 .with_group(MotionGroup::new("intro"))
1578 .with_track(MotionTrack::new(
1579 "card",
1580 "intro",
1581 MotionTrackKind::MorphFramePlan,
1582 MotionTargetRef::new("card", "morph"),
1583 ));
1584
1585 assert!(graph.validate().is_ok());
1586
1587 let duplicate = graph.clone().with_group(MotionGroup::new("intro"));
1588 assert_eq!(
1589 duplicate.validate(),
1590 Err(MotionValidationError::DuplicateGroupId("intro".to_string()))
1591 );
1592
1593 let missing = MotionGraph::new("demo").with_track(MotionTrack::new(
1594 "orphan",
1595 "missing",
1596 MotionTrackKind::ElementKeyframes,
1597 MotionTargetRef::new("orphan", "timeline"),
1598 ));
1599 assert_eq!(
1600 missing.validate(),
1601 Err(MotionValidationError::MissingTrackGroup {
1602 track: "orphan".to_string(),
1603 group: "missing".to_string()
1604 })
1605 );
1606 }
1607
1608 #[test]
1609 fn serializes_stable_motion_shape() {
1610 let graph = MotionGraph::new("demo")
1611 .with_group(
1612 MotionGroup::new("intro")
1613 .with_trigger(MotionTrigger::Visible)
1614 .with_render_lane(MotionRenderLane::MorphFramePlan),
1615 )
1616 .with_track(
1617 MotionTrack::new(
1618 "hero",
1619 "intro",
1620 MotionTrackKind::MorphFramePlan,
1621 MotionTargetRef::new("hero", "morph"),
1622 )
1623 .with_keyframes([
1624 MotionKeyframe::new(1.0).with_value("progress", serde_json::json!(1)),
1625 MotionKeyframe::new(0.0).with_value("progress", serde_json::json!(0)),
1626 ]),
1627 );
1628
1629 let json = graph.to_json().expect("graph serializes");
1630 assert!(json.contains(r#""id":"demo""#));
1631 assert!(json.contains(r#""trigger":"visible""#));
1632 assert!(json.contains(r#""renderLanes":["morph-frame-plan"]"#));
1633 assert!(json.find(r#""offset":0.0"#).unwrap() < json.find(r#""offset":1.0"#).unwrap());
1634 }
1635
1636 #[test]
1637 fn duration_dx_helpers_support_unsuffixed_literals() {
1638 assert_eq!(140.ms().as_millis(), 140);
1639 assert_eq!(140u64.ms().as_millis(), 140);
1640 assert_eq!(140usize.ms().as_millis(), 140);
1641 assert_eq!(1.s().as_secs(), 1);
1642 assert_eq!(2.s().as_secs(), 2);
1643 assert_eq!((-5).ms().as_millis(), 0);
1644 }
1645
1646 #[test]
1647 fn integration_policy_emits_stable_route_manifest_fragments() {
1648 let graph = MotionGraph::new("demo")
1649 .with_group(
1650 MotionGroup::new("intro")
1651 .with_render_lane(MotionRenderLane::ViewTransition)
1652 .with_render_lane(MotionRenderLane::TextFx),
1653 )
1654 .with_track(MotionTrack::new(
1655 "hero",
1656 "intro",
1657 MotionTrackKind::ElementKeyframes,
1658 MotionTargetRef::new("hero", "viewtx"),
1659 ));
1660 let policy = MotionIntegrationPolicy::new(MotionIntegrationTarget::ViewTx)
1661 .profile(MotionPresetProfile::Conservative)
1662 .route_override(
1663 MotionRouteOverride::new("/admin/*")
1664 .enabled(false)
1665 .label("owner", "platform")
1666 .tag("internal"),
1667 )
1668 .tag("visual");
1669
1670 let public = motion_manifest_fragment(&graph, &policy, "/");
1671 let admin = motion_manifest_fragment(&graph, &policy, "/admin/home");
1672
1673 assert!(public.enabled);
1674 assert!(!admin.enabled);
1675 assert_eq!(public.target, "dioxus-viewtx");
1676 assert_eq!(
1677 public.cache_key,
1678 motion_cache_key(&graph, &policy, Some("/"))
1679 );
1680 assert!(
1681 public
1682 .capabilities
1683 .contains(&"motion.lane.textfx".to_string())
1684 );
1685 assert_eq!(
1686 admin.labels.get("owner").map(String::as_str),
1687 Some("platform")
1688 );
1689 assert!(serialize_motion_fragment(&public, MotionSerializationFormat::Json).is_ok());
1690 }
1691
1692 #[test]
1693 fn explain_doctor_and_cache_backend_cover_diagnostics() {
1694 let graph = MotionGraph::new("demo").with_track(MotionTrack::new(
1695 "orphan",
1696 "missing",
1697 MotionTrackKind::Custom,
1698 MotionTargetRef::new("orphan", "custom"),
1699 ));
1700 let policy = MotionIntegrationPolicy::new(MotionIntegrationTarget::Custom(
1701 "dioxus-dxr-cli".to_string(),
1702 ));
1703 let report = explain_motion_integration(&graph, &policy, "/broken");
1704 let doctor = motion_doctor(&graph, &policy);
1705 let mut cache = MotionMemoryCache::default();
1706
1707 assert!(!doctor.ok);
1708 assert!(report.to_text().contains("motion.validation"));
1709 cache.insert(report.cache_key.clone(), report.to_text());
1710 assert!(
1711 cache
1712 .get(&report.cache_key)
1713 .unwrap()
1714 .contains("target: dioxus-dxr-cli")
1715 );
1716 }
1717
1718 #[test]
1719 fn presets_borrowed_targets_and_dictionary_are_deterministic() {
1720 let group = aggressive_motion_group("hero");
1721 let target = motion_target_ref_borrowed("card", "timeline")
1722 .selector("[data-card]")
1723 .into_owned();
1724 let graph = MotionGraph::new("demo")
1725 .with_group(group)
1726 .with_track(MotionTrack::new(
1727 "card",
1728 "hero",
1729 MotionTrackKind::ElementKeyframes,
1730 target,
1731 ));
1732
1733 assert!(graph.groups[0].duration_ms > DEFAULT_MOTION_DURATION_MS);
1734 assert_eq!(
1735 graph.tracks[0].target.selector.as_deref(),
1736 Some("[data-card]")
1737 );
1738 assert_eq!(
1739 compact_motion_dictionary(&graph).get("card").copied(),
1740 Some(0)
1741 );
1742 assert_eq!(motion_compatibility_matrix().len(), 4);
1743 assert!(motion_css_custom_properties(&graph.groups[0]).contains("--dxmotion-duration"));
1744 }
1745
1746 #[test]
1747 fn offload_trace_batch_and_baseline_reports_are_serializable() {
1748 let graph = MotionGraph::new("demo")
1749 .with_group(
1750 MotionGroup::new("hero").with_render_lane(MotionRenderLane::WorkerTownRender),
1751 )
1752 .with_track(
1753 MotionTrack::new(
1754 "card",
1755 "hero",
1756 MotionTrackKind::ElementKeyframes,
1757 MotionTargetRef::new("card", "motion"),
1758 )
1759 .with_keyframes([MotionKeyframe::new(0.0), MotionKeyframe::new(1.0)]),
1760 );
1761 let policy = MotionIntegrationPolicy::new(MotionIntegrationTarget::WorkerTownCore);
1762 let offload = motion_workertown_offload_plan(&graph, &policy, "/route");
1763 let mut events = Vec::new();
1764 let traced = motion_manifest_fragment_with_trace(&graph, &policy, "/route", |event| {
1765 events.push(event.clone())
1766 });
1767 let batch =
1768 MotionBatchRequest::new("/route", vec![graph.clone()]).deterministic_parallel(true);
1769 let fragments = process_motion_batch(&batch, &policy);
1770 let baseline = motion_baseline_report(&graph);
1771
1772 assert!(offload.serializable);
1773 assert_eq!(offload.worker_task, "motion.prepare-fragment");
1774 assert_eq!(events[0].decision, "emit");
1775 assert_eq!(traced.cache_key, fragments[0].cache_key);
1776 assert_eq!(baseline.group_count, 1);
1777 assert_eq!(baseline.track_count, 1);
1778 assert_eq!(baseline.keyframe_count, 2);
1779 assert!(baseline.estimated_operations >= 4);
1780 }
1781}