Skip to main content

dioxus_motion_core/
lib.rs

1//! Shared motion graph primitives for Dioxus animation packages.
2//!
3//! This crate deliberately avoids DOM/runtime concepts. Package crates convert their
4//! existing configs into this graph, and SSR runtimes use the same shape to share
5//! scheduling, telemetry, and control semantics.
6
7use 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}