use std::borrow::Cow;
use std::collections::{BTreeMap, BTreeSet};
use std::time::Duration;
use serde::{Deserialize, Serialize};
pub const DEFAULT_MOTION_DURATION_MS: u32 = 520;
pub const DEFAULT_MOTION_STAGGER_MS: u32 = 32;
pub const DIOXUS_MOTION_CORE_VERSION: &str = env!("CARGO_PKG_VERSION");
pub type Motion = MotionGraph;
pub type MotionGrp = MotionGroup;
pub type MotionKf = MotionKeyframe;
pub fn motion(id: impl Into<String>) -> MotionGraph {
MotionGraph::new(id)
}
pub fn motion_group(id: impl Into<String>) -> MotionGroup {
MotionGroup::new(id)
}
pub fn keyframe(offset: f32) -> MotionKeyframe {
MotionKeyframe::new(offset)
}
pub trait DurationDx {
fn ms(self) -> Duration;
fn s(self) -> Duration;
}
macro_rules! impl_duration_dx_unsigned {
($($ty:ty),* $(,)?) => {
$(
impl DurationDx for $ty {
fn ms(self) -> Duration {
Duration::from_millis(self as u64)
}
fn s(self) -> Duration {
Duration::from_secs(self as u64)
}
}
)*
};
}
macro_rules! impl_duration_dx_signed {
($($ty:ty),* $(,)?) => {
$(
impl DurationDx for $ty {
fn ms(self) -> Duration {
Duration::from_millis(self.max(0) as u64)
}
fn s(self) -> Duration {
Duration::from_secs(self.max(0) as u64)
}
}
)*
};
}
impl_duration_dx_unsigned!(u8, u16, u32, u64, usize);
impl_duration_dx_signed!(i8, i16, i32, i64, isize);
pub fn duration_ms_u32(duration: Duration) -> u32 {
duration.as_millis().min(u128::from(u32::MAX)) as u32
}
pub fn duration_ms_u16(duration: Duration) -> u16 {
duration.as_millis().min(u128::from(u16::MAX)) as u16
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "kebab-case")]
pub enum MotionTrigger {
#[default]
Manual,
Load,
Visible,
AllVisible,
AnyVisible,
Hover,
Click,
ScrollProgress,
RouteEnter,
RouteExit,
StateChange,
}
impl MotionTrigger {
pub const fn as_attr(self) -> &'static str {
match self {
Self::Manual => "manual",
Self::Load => "load",
Self::Visible => "visible",
Self::AllVisible => "all-visible",
Self::AnyVisible => "any-visible",
Self::Hover => "hover",
Self::Click => "click",
Self::ScrollProgress => "scroll-progress",
Self::RouteEnter => "route-enter",
Self::RouteExit => "route-exit",
Self::StateChange => "state-change",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "kebab-case")]
pub enum MotionPlayback {
#[default]
Once,
Replay,
Loop,
Yoyo,
Reverse,
Alternate,
Infinite,
Count(u16),
}
impl MotionPlayback {
pub const fn as_attr(self) -> &'static str {
match self {
Self::Once => "once",
Self::Replay => "replay",
Self::Loop => "loop",
Self::Yoyo => "yoyo",
Self::Reverse => "reverse",
Self::Alternate => "alternate",
Self::Infinite => "infinite",
Self::Count(_) => "count",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, Default)]
#[serde(rename_all = "kebab-case")]
pub enum MotionCurve {
Linear,
EaseIn,
#[default]
EaseOut,
EaseInOut,
Spring,
CubicBezier(f32, f32, f32, f32),
}
impl MotionCurve {
pub fn css_value(self) -> String {
match self {
Self::Linear => "linear".to_string(),
Self::EaseIn => "cubic-bezier(.42,0,1,1)".to_string(),
Self::EaseOut => "cubic-bezier(0,0,.2,1)".to_string(),
Self::EaseInOut => "cubic-bezier(.42,0,.58,1)".to_string(),
Self::Spring => "cubic-bezier(.18,.89,.32,1.18)".to_string(),
Self::CubicBezier(x1, y1, x2, y2) => {
format!("cubic-bezier({x1},{y1},{x2},{y2})")
}
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "kebab-case")]
pub enum MotionReducedMotionPolicy {
Initial,
#[default]
Final,
Static,
Animate,
FadeOnly,
}
impl MotionReducedMotionPolicy {
pub const fn as_attr(self) -> &'static str {
match self {
Self::Initial => "initial",
Self::Final => "final",
Self::Static => "static",
Self::Animate => "animate",
Self::FadeOnly => "fade-only",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "kebab-case")]
pub enum MotionRenderLane {
#[default]
DomWaapi,
LayoutFlip,
ViewTransition,
WorkerCanvas2d,
WorkerTownRender,
Text,
TextFx,
MorphFramePlan,
MorphScene,
}
impl MotionRenderLane {
pub const fn as_attr(self) -> &'static str {
match self {
Self::DomWaapi => "dom-waapi",
Self::LayoutFlip => "layout-flip",
Self::ViewTransition => "view-transition",
Self::WorkerCanvas2d => "worker-canvas-2d",
Self::WorkerTownRender => "workertown-render",
Self::Text => "text",
Self::TextFx => "textfx",
Self::MorphFramePlan => "morph-frame-plan",
Self::MorphScene => "morph-scene",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "kebab-case")]
pub enum MotionTrackKind {
#[default]
ElementKeyframes,
Text,
TextFx,
MorphConfig,
MorphScene,
MorphFramePlan,
RouteMorph,
Custom,
}
impl MotionTrackKind {
pub const fn as_attr(self) -> &'static str {
match self {
Self::ElementKeyframes => "element-keyframes",
Self::Text => "text",
Self::TextFx => "textfx",
Self::MorphConfig => "morph-config",
Self::MorphScene => "morph-scene",
Self::MorphFramePlan => "morph-frame-plan",
Self::RouteMorph => "route-morph",
Self::Custom => "custom",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MotionTargetRef {
pub id: String,
pub package: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub selector: Option<String>,
}
impl MotionTargetRef {
pub fn new(id: impl Into<String>, package: impl Into<String>) -> Self {
Self {
id: id.into(),
package: package.into(),
selector: None,
}
}
pub fn with_selector(mut self, selector: impl Into<String>) -> Self {
self.selector = Some(selector.into());
self
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MotionKeyframe {
pub offset: f32,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub values: BTreeMap<String, serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub curve: Option<MotionCurve>,
}
impl MotionKeyframe {
pub fn new(offset: f32) -> Self {
Self {
offset: clamp_unit(offset),
values: BTreeMap::new(),
curve: None,
}
}
pub fn with_value(mut self, key: impl Into<String>, value: serde_json::Value) -> Self {
self.values.insert(key.into(), value);
self
}
pub fn value(self, key: impl Into<String>, value: serde_json::Value) -> Self {
self.with_value(key, value)
}
pub fn with_curve(mut self, curve: MotionCurve) -> Self {
self.curve = Some(curve);
self
}
pub fn curve(self, curve: MotionCurve) -> Self {
self.with_curve(curve)
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MotionGroup {
pub id: String,
pub trigger: MotionTrigger,
pub duration_ms: u32,
pub delay_ms: u32,
pub stagger_ms: u32,
pub curve: MotionCurve,
pub playback: MotionPlayback,
pub reduced_motion: MotionReducedMotionPolicy,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub render_lanes: Vec<MotionRenderLane>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub metadata: BTreeMap<String, serde_json::Value>,
}
impl MotionGroup {
pub fn new(id: impl Into<String>) -> Self {
Self {
id: id.into(),
trigger: MotionTrigger::Manual,
duration_ms: DEFAULT_MOTION_DURATION_MS,
delay_ms: 0,
stagger_ms: DEFAULT_MOTION_STAGGER_MS,
curve: MotionCurve::EaseOut,
playback: MotionPlayback::Once,
reduced_motion: MotionReducedMotionPolicy::Final,
render_lanes: Vec::new(),
metadata: BTreeMap::new(),
}
}
pub fn with_trigger(mut self, trigger: MotionTrigger) -> Self {
self.trigger = trigger;
self
}
pub fn trigger(self, trigger: MotionTrigger) -> Self {
self.with_trigger(trigger)
}
pub fn with_duration_ms(mut self, duration_ms: u32) -> Self {
self.duration_ms = duration_ms.max(1);
self
}
pub fn dur_ms(self, duration_ms: u32) -> Self {
self.with_duration_ms(duration_ms)
}
pub fn with_delay_ms(mut self, delay_ms: u32) -> Self {
self.delay_ms = delay_ms;
self
}
pub fn delay_ms(self, delay_ms: u32) -> Self {
self.with_delay_ms(delay_ms)
}
pub fn with_stagger_ms(mut self, stagger_ms: u32) -> Self {
self.stagger_ms = stagger_ms;
self
}
pub fn stagger_ms(self, stagger_ms: u32) -> Self {
self.with_stagger_ms(stagger_ms)
}
pub fn with_curve(mut self, curve: MotionCurve) -> Self {
self.curve = curve;
self
}
pub fn curve(self, curve: MotionCurve) -> Self {
self.with_curve(curve)
}
pub fn with_playback(mut self, playback: MotionPlayback) -> Self {
self.playback = playback;
self
}
pub fn playback(self, playback: MotionPlayback) -> Self {
self.with_playback(playback)
}
pub fn with_reduced_motion(mut self, policy: MotionReducedMotionPolicy) -> Self {
self.reduced_motion = policy;
self
}
pub fn reduced(self, policy: MotionReducedMotionPolicy) -> Self {
self.with_reduced_motion(policy)
}
pub fn with_render_lane(mut self, lane: MotionRenderLane) -> Self {
if !self.render_lanes.contains(&lane) {
self.render_lanes.push(lane);
}
self
}
pub fn lane(self, lane: MotionRenderLane) -> Self {
self.with_render_lane(lane)
}
pub fn with_metadata(mut self, key: impl Into<String>, value: serde_json::Value) -> Self {
self.metadata.insert(key.into(), value);
self
}
pub fn meta(self, key: impl Into<String>, value: serde_json::Value) -> Self {
self.with_metadata(key, value)
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MotionTrack {
pub id: String,
pub group: String,
pub kind: MotionTrackKind,
pub target: MotionTargetRef,
#[serde(skip_serializing_if = "Option::is_none")]
pub duration_ms: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub delay_ms: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub stagger_index: Option<u16>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub keyframes: Vec<MotionKeyframe>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub metadata: BTreeMap<String, serde_json::Value>,
}
impl MotionTrack {
pub fn new(
id: impl Into<String>,
group: impl Into<String>,
kind: MotionTrackKind,
target: MotionTargetRef,
) -> Self {
Self {
id: id.into(),
group: group.into(),
kind,
target,
duration_ms: None,
delay_ms: None,
stagger_index: None,
keyframes: Vec::new(),
metadata: BTreeMap::new(),
}
}
pub fn with_duration_ms(mut self, duration_ms: u32) -> Self {
self.duration_ms = Some(duration_ms.max(1));
self
}
pub fn dur_ms(self, duration_ms: u32) -> Self {
self.with_duration_ms(duration_ms)
}
pub fn with_delay_ms(mut self, delay_ms: u32) -> Self {
self.delay_ms = Some(delay_ms);
self
}
pub fn delay_ms(self, delay_ms: u32) -> Self {
self.with_delay_ms(delay_ms)
}
pub fn with_stagger_index(mut self, index: u16) -> Self {
self.stagger_index = Some(index);
self
}
pub fn stagger(self, index: u16) -> Self {
self.with_stagger_index(index)
}
pub fn with_keyframes(mut self, keyframes: impl Into<Vec<MotionKeyframe>>) -> Self {
let mut keyframes = keyframes.into();
keyframes.sort_by(|a, b| a.offset.total_cmp(&b.offset));
self.keyframes = keyframes;
self
}
pub fn frames(self, keyframes: impl Into<Vec<MotionKeyframe>>) -> Self {
self.with_keyframes(keyframes)
}
pub fn with_metadata(mut self, key: impl Into<String>, value: serde_json::Value) -> Self {
self.metadata.insert(key.into(), value);
self
}
pub fn meta(self, key: impl Into<String>, value: serde_json::Value) -> Self {
self.with_metadata(key, value)
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MotionGraph {
pub id: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub groups: Vec<MotionGroup>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tracks: Vec<MotionTrack>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub metadata: BTreeMap<String, serde_json::Value>,
}
impl MotionGraph {
pub fn new(id: impl Into<String>) -> Self {
Self {
id: id.into(),
groups: Vec::new(),
tracks: Vec::new(),
metadata: BTreeMap::new(),
}
}
pub fn with_group(mut self, group: MotionGroup) -> Self {
self.groups.push(group);
self
}
pub fn group(self, group: MotionGroup) -> Self {
self.with_group(group)
}
pub fn with_track(mut self, track: MotionTrack) -> Self {
self.tracks.push(track);
self
}
pub fn track(self, track: MotionTrack) -> Self {
self.with_track(track)
}
pub fn with_metadata(mut self, key: impl Into<String>, value: serde_json::Value) -> Self {
self.metadata.insert(key.into(), value);
self
}
pub fn meta(self, key: impl Into<String>, value: serde_json::Value) -> Self {
self.with_metadata(key, value)
}
pub fn validate(&self) -> Result<(), MotionValidationError> {
validate_motion_graph(self)
}
pub fn to_json(&self) -> serde_json::Result<String> {
serde_json::to_string(self)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum MotionIntegrationTarget {
Morph,
MotionSsr,
TextFx,
Theme,
Timeline,
TimelineCore,
ViewTx,
ViewTxCore,
ViewTxSsr,
StrataCore,
StrataSsr,
Resume,
ResumeSsr,
AssetBudget,
CssOpt,
HtmlOpt,
JsOpt,
NativePort,
NativePortCore,
NativePortCli,
WorkerTownCore,
WorkerTownSsr,
DxrCli,
Custom(String),
}
impl MotionIntegrationTarget {
pub fn package_name(&self) -> &str {
match self {
Self::Morph => "dioxus-morph-core",
Self::MotionSsr => "dioxus-motion-ssr",
Self::TextFx => "dioxus-textfx",
Self::Theme => "dioxus-theme",
Self::Timeline => "dioxus-timeline",
Self::TimelineCore => "dioxus-timeline-core",
Self::ViewTx => "dioxus-viewtx",
Self::ViewTxCore => "dioxus-viewtx-core",
Self::ViewTxSsr => "dioxus-viewtx-ssr",
Self::StrataCore => "dioxus-strata-core",
Self::StrataSsr => "dioxus-strata-ssr",
Self::Resume => "dioxus-resume",
Self::ResumeSsr => "dioxus-resume-ssr",
Self::AssetBudget => "dioxus-asset-budget",
Self::CssOpt => "dioxus-css-opt",
Self::HtmlOpt => "dioxus-html-opt",
Self::JsOpt => "dioxus-js-opt",
Self::NativePort => "dioxus-native-port",
Self::NativePortCore => "dioxus-native-port-core",
Self::NativePortCli => "dioxus-native-port-cli",
Self::WorkerTownCore => "dioxus-workertown-core",
Self::WorkerTownSsr => "dioxus-workertown-ssr",
Self::DxrCli => "dioxus-dxr-cli",
Self::Custom(package) => package.as_str(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "kebab-case")]
pub enum MotionPresetProfile {
Conservative,
#[default]
Balanced,
Aggressive,
}
impl MotionPresetProfile {
pub const fn as_attr(self) -> &'static str {
match self {
Self::Conservative => "conservative",
Self::Balanced => "balanced",
Self::Aggressive => "aggressive",
}
}
pub fn apply_to_group(self, group: MotionGroup) -> MotionGroup {
match self {
Self::Conservative => {
let duration_ms = group.duration_ms.min(DEFAULT_MOTION_DURATION_MS);
let stagger_ms = group.stagger_ms.min(DEFAULT_MOTION_STAGGER_MS);
group
.with_duration_ms(duration_ms)
.with_stagger_ms(stagger_ms)
.with_reduced_motion(MotionReducedMotionPolicy::Static)
}
Self::Balanced => group,
Self::Aggressive => {
let duration_ms = group.duration_ms.max(DEFAULT_MOTION_DURATION_MS + 120);
let stagger_ms = group.stagger_ms.max(DEFAULT_MOTION_STAGGER_MS);
group
.with_duration_ms(duration_ms)
.with_stagger_ms(stagger_ms)
}
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "kebab-case")]
pub enum MotionDiagnosticLevel {
Off,
Error,
Warn,
#[default]
Info,
Verbose,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MotionDiagnosticVerbosity {
pub build: MotionDiagnosticLevel,
pub ssr: MotionDiagnosticLevel,
pub runtime: MotionDiagnosticLevel,
}
impl Default for MotionDiagnosticVerbosity {
fn default() -> Self {
Self {
build: MotionDiagnosticLevel::Info,
ssr: MotionDiagnosticLevel::Warn,
runtime: MotionDiagnosticLevel::Error,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MotionDiagnostic {
pub code: String,
pub message: String,
pub remediation: String,
pub level: MotionDiagnosticLevel,
#[serde(skip_serializing_if = "Option::is_none")]
pub route: Option<String>,
}
impl MotionDiagnostic {
pub fn new(
code: impl Into<String>,
message: impl Into<String>,
remediation: impl Into<String>,
) -> Self {
Self {
code: code.into(),
message: message.into(),
remediation: remediation.into(),
level: MotionDiagnosticLevel::Info,
route: None,
}
}
pub fn level(mut self, level: MotionDiagnosticLevel) -> Self {
self.level = level;
self
}
pub fn route(mut self, route: impl Into<String>) -> Self {
self.route = Some(route.into());
self
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MotionBudget {
pub max_bytes: u32,
pub max_records: u32,
pub max_duration_ms: u32,
#[serde(default)]
pub warn_only: bool,
}
impl Default for MotionBudget {
fn default() -> Self {
Self {
max_bytes: 24 * 1024,
max_records: 128,
max_duration_ms: DEFAULT_MOTION_DURATION_MS * 2,
warn_only: true,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MotionRouteOverride {
pub route: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub enabled: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub duration_ms: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub reduced_motion: Option<MotionReducedMotionPolicy>,
#[serde(skip_serializing_if = "Option::is_none")]
pub profile: Option<MotionPresetProfile>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub labels: BTreeMap<String, String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tags: Vec<String>,
}
impl MotionRouteOverride {
pub fn new(route: impl Into<String>) -> Self {
Self {
route: route.into(),
enabled: None,
duration_ms: None,
reduced_motion: None,
profile: None,
labels: BTreeMap::new(),
tags: Vec::new(),
}
}
pub fn enabled(mut self, enabled: bool) -> Self {
self.enabled = Some(enabled);
self
}
pub fn duration_ms(mut self, duration_ms: u32) -> Self {
self.duration_ms = Some(duration_ms.max(1));
self
}
pub fn reduced_motion(mut self, reduced_motion: MotionReducedMotionPolicy) -> Self {
self.reduced_motion = Some(reduced_motion);
self
}
pub fn profile(mut self, profile: MotionPresetProfile) -> Self {
self.profile = Some(profile);
self
}
pub fn label(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
self.labels.insert(key.into(), value.into());
self
}
pub fn tag(mut self, tag: impl Into<String>) -> Self {
self.tags.push(tag.into());
self
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MotionIntegrationPolicy {
pub package: String,
pub target: MotionIntegrationTarget,
pub profile: MotionPresetProfile,
pub route_enabled_by_default: bool,
pub reduced_motion: MotionReducedMotionPolicy,
pub base_path: String,
pub config_id: String,
pub asset_name: String,
pub budget: MotionBudget,
pub diagnostics: MotionDiagnosticVerbosity,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub route_overrides: Vec<MotionRouteOverride>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub labels: BTreeMap<String, String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tags: Vec<String>,
}
impl MotionIntegrationPolicy {
pub fn new(target: MotionIntegrationTarget) -> Self {
Self {
package: "dioxus-motion-core".to_string(),
target,
profile: MotionPresetProfile::Balanced,
route_enabled_by_default: true,
reduced_motion: MotionReducedMotionPolicy::Final,
base_path: "/assets".to_string(),
config_id: "__DXMOTION_CONFIG__".to_string(),
asset_name: "dioxus-motion.json".to_string(),
budget: MotionBudget::default(),
diagnostics: MotionDiagnosticVerbosity::default(),
route_overrides: Vec::new(),
labels: BTreeMap::new(),
tags: Vec::new(),
}
}
pub fn target_package(&self) -> &str {
self.target.package_name()
}
pub fn profile(mut self, profile: MotionPresetProfile) -> Self {
self.profile = profile;
self
}
pub fn route_enabled_by_default(mut self, enabled: bool) -> Self {
self.route_enabled_by_default = enabled;
self
}
pub fn reduced_motion(mut self, reduced_motion: MotionReducedMotionPolicy) -> Self {
self.reduced_motion = reduced_motion;
self
}
pub fn base_path(mut self, base_path: impl Into<String>) -> Self {
self.base_path = base_path.into();
self
}
pub fn ids(mut self, config_id: impl Into<String>, asset_name: impl Into<String>) -> Self {
self.config_id = config_id.into();
self.asset_name = asset_name.into();
self
}
pub fn budget(mut self, budget: MotionBudget) -> Self {
self.budget = budget;
self
}
pub fn diagnostics(mut self, diagnostics: MotionDiagnosticVerbosity) -> Self {
self.diagnostics = diagnostics;
self
}
pub fn route_override(mut self, route_override: MotionRouteOverride) -> Self {
self.route_overrides.push(route_override);
self.route_overrides
.sort_by(|a, b| a.route.cmp(&b.route).then_with(|| a.tags.cmp(&b.tags)));
self
}
pub fn label(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
self.labels.insert(key.into(), value.into());
self
}
pub fn tag(mut self, tag: impl Into<String>) -> Self {
self.tags.push(tag.into());
self.tags.sort();
self.tags.dedup();
self
}
pub fn route_enabled(&self, route: &str) -> bool {
self.route_override_for(route)
.and_then(|route_override| route_override.enabled)
.unwrap_or(self.route_enabled_by_default)
}
pub fn route_override_for(&self, route: &str) -> Option<&MotionRouteOverride> {
self.route_overrides
.iter()
.find(|route_override| route_matches(&route_override.route, route))
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MotionManifestFragment {
pub package: String,
pub target: String,
pub cache_key: String,
pub config_ref: String,
pub route: String,
pub enabled: bool,
pub reduced_motion_aware: bool,
pub runtime_base_path: String,
pub asset_name: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub capabilities: Vec<String>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub labels: BTreeMap<String, String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tags: Vec<String>,
}
impl MotionManifestFragment {
pub fn to_json(&self) -> serde_json::Result<String> {
serde_json::to_string(self)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MotionExplainReport {
pub package: String,
pub target: String,
pub profile: MotionPresetProfile,
pub route: String,
pub enabled: bool,
pub cache_key: String,
pub reasons: Vec<String>,
pub diagnostics: Vec<MotionDiagnostic>,
}
impl MotionExplainReport {
pub fn to_text(&self) -> String {
let mut output = vec![
format!("package: {}", self.package),
format!("target: {}", self.target),
format!("route: {}", self.route),
format!("profile: {}", self.profile.as_attr()),
format!("enabled: {}", self.enabled),
format!("cache-key: {}", self.cache_key),
];
output.extend(
self.reasons
.iter()
.map(|reason| format!("reason: {reason}")),
);
output.extend(
self.diagnostics
.iter()
.map(|diagnostic| format!("{}: {}", diagnostic.code, diagnostic.message)),
);
output.join("\n")
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MotionCompatibilityRow {
pub surface: String,
pub supported: bool,
pub behavior: String,
pub validation: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MotionOffloadPlan {
pub package: String,
pub route: String,
pub worker_task: String,
pub cache_key: String,
pub serializable: bool,
pub fallback: String,
pub estimated_operations: u32,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub transfer_fields: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MotionTraceEvent {
pub package: String,
pub phase: String,
pub route: String,
pub cache_key: String,
pub decision: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MotionBatchRequest {
pub deterministic_parallel: bool,
pub route: String,
pub graphs: Vec<MotionGraph>,
}
impl MotionBatchRequest {
pub fn new(route: impl Into<String>, graphs: impl Into<Vec<MotionGraph>>) -> Self {
Self {
deterministic_parallel: false,
route: route.into(),
graphs: graphs.into(),
}
}
pub fn deterministic_parallel(mut self, enabled: bool) -> Self {
self.deterministic_parallel = enabled;
self
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MotionBaselineReport {
pub package: String,
pub output_bytes: usize,
pub group_count: usize,
pub track_count: usize,
pub keyframe_count: usize,
pub estimated_operations: usize,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum MotionSerializationFormat {
Json,
PrettyJson,
CompactJson,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MotionDoctorReport {
pub ok: bool,
pub cache_key: String,
pub diagnostics: Vec<MotionDiagnostic>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum MotionPolicyDecision<T> {
Accept(T),
Reject(MotionDiagnostic),
Rewrite(T, MotionDiagnostic),
}
pub trait MotionArtifactCache {
fn get(&self, key: &str) -> Option<String>;
fn insert(&mut self, key: String, value: String);
}
#[derive(Debug, Clone, Default)]
pub struct MotionMemoryCache {
entries: BTreeMap<String, String>,
}
impl MotionArtifactCache for MotionMemoryCache {
fn get(&self, key: &str) -> Option<String> {
self.entries.get(key).cloned()
}
fn insert(&mut self, key: String, value: String) {
self.entries.insert(key, value);
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BorrowedMotionTargetRef<'a> {
pub id: Cow<'a, str>,
pub package: Cow<'a, str>,
pub selector: Option<Cow<'a, str>>,
}
impl<'a> BorrowedMotionTargetRef<'a> {
pub fn new(id: impl Into<Cow<'a, str>>, package: impl Into<Cow<'a, str>>) -> Self {
Self {
id: id.into(),
package: package.into(),
selector: None,
}
}
pub fn selector(mut self, selector: impl Into<Cow<'a, str>>) -> Self {
self.selector = Some(selector.into());
self
}
pub fn into_owned(self) -> MotionTargetRef {
let mut target = MotionTargetRef::new(self.id.into_owned(), self.package.into_owned());
if let Some(selector) = self.selector {
target = target.with_selector(selector.into_owned());
}
target
}
}
pub fn motion_target_ref_borrowed<'a>(
id: impl Into<Cow<'a, str>>,
package: impl Into<Cow<'a, str>>,
) -> BorrowedMotionTargetRef<'a> {
BorrowedMotionTargetRef::new(id, package)
}
pub fn conservative_motion_group(id: impl Into<String>) -> MotionGroup {
MotionPresetProfile::Conservative.apply_to_group(MotionGroup::new(id))
}
pub fn balanced_motion_group(id: impl Into<String>) -> MotionGroup {
MotionPresetProfile::Balanced.apply_to_group(MotionGroup::new(id))
}
pub fn aggressive_motion_group(id: impl Into<String>) -> MotionGroup {
MotionPresetProfile::Aggressive.apply_to_group(MotionGroup::new(id))
}
pub fn motion_cache_key(
graph: &MotionGraph,
policy: &MotionIntegrationPolicy,
route: Option<&str>,
) -> String {
let graph_json = graph.to_json().unwrap_or_else(|_| format!("{graph:?}"));
let policy_json = serde_json::to_string(policy).unwrap_or_else(|_| format!("{policy:?}"));
stable_cache_key(&[
DIOXUS_MOTION_CORE_VERSION,
&graph_json,
&policy_json,
route.unwrap_or_default(),
])
}
pub fn motion_manifest_fragment(
graph: &MotionGraph,
policy: &MotionIntegrationPolicy,
route: impl AsRef<str>,
) -> MotionManifestFragment {
let route = route.as_ref();
let route_override = policy.route_override_for(route);
let mut labels = policy.labels.clone();
let mut tags = policy.tags.clone();
if let Some(route_override) = route_override {
labels.extend(route_override.labels.clone());
tags.extend(route_override.tags.clone());
}
tags.sort();
tags.dedup();
let enabled = policy.route_enabled(route) && !is_default_motion_graph(graph);
MotionManifestFragment {
package: policy.package.clone(),
target: policy.target_package().to_string(),
cache_key: motion_cache_key(graph, policy, Some(route)),
config_ref: policy.config_id.clone(),
route: route.to_string(),
enabled,
reduced_motion_aware: true,
runtime_base_path: policy.base_path.clone(),
asset_name: policy.asset_name.clone(),
capabilities: motion_capabilities(graph, policy),
labels,
tags,
}
}
pub fn explain_motion_integration(
graph: &MotionGraph,
policy: &MotionIntegrationPolicy,
route: impl AsRef<str>,
) -> MotionExplainReport {
let route = route.as_ref();
let mut diagnostics = Vec::new();
if let Err(error) = graph.validate() {
diagnostics.push(
MotionDiagnostic::new(
"motion.validation",
format!("{error:?}"),
"ensure every track references an existing unique group",
)
.level(MotionDiagnosticLevel::Error)
.route(route),
);
}
if graph.groups.is_empty() && graph.tracks.is_empty() {
diagnostics.push(
MotionDiagnostic::new(
"motion.empty",
"motion graph has no groups or tracks",
"skip runtime emission or add at least one group and track",
)
.level(MotionDiagnosticLevel::Warn)
.route(route),
);
}
let enabled = policy.route_enabled(route) && !is_default_motion_graph(graph);
MotionExplainReport {
package: policy.package.clone(),
target: policy.target_package().to_string(),
profile: policy
.route_override_for(route)
.and_then(|route_override| route_override.profile)
.unwrap_or(policy.profile),
route: route.to_string(),
enabled,
cache_key: motion_cache_key(graph, policy, Some(route)),
reasons: vec![
format!(
"runtime emission is {} for route",
if enabled { "enabled" } else { "disabled" }
),
format!(
"reduced motion policy resolves to {}",
policy.reduced_motion.as_attr()
),
"manifest labels and tags are stable-sorted for downstream filters".to_string(),
],
diagnostics,
}
}
pub fn motion_compatibility_matrix() -> Vec<MotionCompatibilityRow> {
vec![
MotionCompatibilityRow {
surface: "web".to_string(),
supported: true,
behavior: "WAAPI/CSS runtime lanes with reduced-motion gates".to_string(),
validation: "cargo check --target wasm32-unknown-unknown --features web".to_string(),
},
MotionCompatibilityRow {
surface: "server".to_string(),
supported: true,
behavior: "serializable manifest fragments and SSR runtime decisions".to_string(),
validation: "cargo check --features server".to_string(),
},
MotionCompatibilityRow {
surface: "native".to_string(),
supported: true,
behavior: "native-port hints carry runtime mode, labels, and fallback policy"
.to_string(),
validation: "cargo check --features native".to_string(),
},
MotionCompatibilityRow {
surface: "cli".to_string(),
supported: true,
behavior: "stable cache keys and compact JSON reports for audit tools".to_string(),
validation: "cargo test -p dioxus-motion-core".to_string(),
},
]
}
pub fn motion_workertown_offload_plan(
graph: &MotionGraph,
policy: &MotionIntegrationPolicy,
route: impl AsRef<str>,
) -> MotionOffloadPlan {
let route = route.as_ref();
MotionOffloadPlan {
package: policy.package.clone(),
route: route.to_string(),
worker_task: "motion.prepare-fragment".to_string(),
cache_key: motion_cache_key(graph, policy, Some(route)),
serializable: graph.to_json().is_ok(),
fallback: "main-thread-shared-motion".to_string(),
estimated_operations: motion_estimated_operations(graph) as u32,
transfer_fields: vec![
"groups".to_string(),
"tracks".to_string(),
"metadata".to_string(),
],
}
}
pub fn motion_doctor(graph: &MotionGraph, policy: &MotionIntegrationPolicy) -> MotionDoctorReport {
let report = explain_motion_integration(graph, policy, "/");
MotionDoctorReport {
ok: report
.diagnostics
.iter()
.all(|diagnostic| diagnostic.level != MotionDiagnosticLevel::Error),
cache_key: report.cache_key,
diagnostics: report.diagnostics,
}
}
pub fn motion_manifest_fragment_with_trace<F>(
graph: &MotionGraph,
policy: &MotionIntegrationPolicy,
route: impl AsRef<str>,
mut trace: F,
) -> MotionManifestFragment
where
F: FnMut(&MotionTraceEvent),
{
let route = route.as_ref();
let fragment = motion_manifest_fragment(graph, policy, route);
trace(&MotionTraceEvent {
package: fragment.package.clone(),
phase: "motion.manifest".to_string(),
route: route.to_string(),
cache_key: fragment.cache_key.clone(),
decision: if fragment.enabled {
"emit".to_string()
} else {
"skip".to_string()
},
});
fragment
}
pub fn motion_css_custom_properties(group: &MotionGroup) -> String {
format!(
"--dxmotion-duration:{}ms;--dxmotion-delay:{}ms;--dxmotion-stagger:{}ms;--dxmotion-ease:{};",
group.duration_ms,
group.delay_ms,
group.stagger_ms,
group.curve.css_value()
)
}
pub fn serialize_motion_fragment(
fragment: &MotionManifestFragment,
format: MotionSerializationFormat,
) -> serde_json::Result<String> {
match format {
MotionSerializationFormat::Json | MotionSerializationFormat::CompactJson => {
serde_json::to_string(fragment)
}
MotionSerializationFormat::PrettyJson => serde_json::to_string_pretty(fragment),
}
}
pub fn apply_motion_policy_hook<F>(
graph: MotionGraph,
hook: F,
) -> Result<MotionGraph, MotionDiagnostic>
where
F: FnOnce(MotionGraph) -> MotionPolicyDecision<MotionGraph>,
{
match hook(graph) {
MotionPolicyDecision::Accept(graph) | MotionPolicyDecision::Rewrite(graph, _) => Ok(graph),
MotionPolicyDecision::Reject(diagnostic) => Err(diagnostic),
}
}
pub fn batch_motion_manifest(
graphs: &[MotionGraph],
policy: &MotionIntegrationPolicy,
route: impl AsRef<str>,
) -> Vec<MotionManifestFragment> {
let route = route.as_ref();
let mut fragments = graphs
.iter()
.map(|graph| motion_manifest_fragment(graph, policy, route))
.collect::<Vec<_>>();
fragments.sort_by(|a, b| a.cache_key.cmp(&b.cache_key));
fragments
}
pub fn process_motion_batch(
request: &MotionBatchRequest,
policy: &MotionIntegrationPolicy,
) -> Vec<MotionManifestFragment> {
let mut fragments = batch_motion_manifest(&request.graphs, policy, &request.route);
if request.deterministic_parallel {
fragments.sort_by(|a, b| {
a.cache_key
.cmp(&b.cache_key)
.then_with(|| a.route.cmp(&b.route))
});
}
fragments
}
pub fn motion_baseline_report(graph: &MotionGraph) -> MotionBaselineReport {
let output_bytes = graph.to_json().map(|json| json.len()).unwrap_or_default();
MotionBaselineReport {
package: "dioxus-motion-core".to_string(),
output_bytes,
group_count: graph.groups.len(),
track_count: graph.tracks.len(),
keyframe_count: graph
.tracks
.iter()
.map(|track| track.keyframes.len())
.sum::<usize>(),
estimated_operations: motion_estimated_operations(graph),
}
}
pub fn compact_motion_dictionary(graph: &MotionGraph) -> BTreeMap<String, u16> {
let mut values = BTreeSet::new();
values.insert(graph.id.clone());
for group in &graph.groups {
values.insert(group.id.clone());
values.insert(group.trigger.as_attr().to_string());
values.insert(group.playback.as_attr().to_string());
values.insert(group.reduced_motion.as_attr().to_string());
for lane in &group.render_lanes {
values.insert(lane.as_attr().to_string());
}
}
for track in &graph.tracks {
values.insert(track.id.clone());
values.insert(track.group.clone());
values.insert(track.kind.as_attr().to_string());
values.insert(track.target.id.clone());
values.insert(track.target.package.clone());
}
values
.into_iter()
.enumerate()
.map(|(index, value)| (value, index.min(u16::MAX as usize) as u16))
.collect()
}
fn is_default_motion_graph(graph: &MotionGraph) -> bool {
graph.groups.is_empty() && graph.tracks.is_empty()
}
fn motion_capabilities(graph: &MotionGraph, policy: &MotionIntegrationPolicy) -> Vec<String> {
let mut capabilities = BTreeSet::new();
capabilities.insert("motion.schedule".to_string());
capabilities.insert("motion.reduced-motion".to_string());
capabilities.insert(format!("motion.target.{}", policy.target_package()));
for group in &graph.groups {
for lane in &group.render_lanes {
capabilities.insert(format!("motion.lane.{}", lane.as_attr()));
}
}
capabilities.into_iter().collect()
}
fn motion_estimated_operations(graph: &MotionGraph) -> usize {
graph.groups.len()
+ graph.tracks.len()
+ graph
.tracks
.iter()
.map(|track| track.keyframes.len().max(1))
.sum::<usize>()
}
fn route_matches(pattern: &str, route: &str) -> bool {
if pattern == route || pattern == "*" {
return true;
}
pattern
.strip_suffix('*')
.is_some_and(|prefix| route.starts_with(prefix))
}
fn stable_cache_key(parts: &[&str]) -> String {
let mut hash = 0xcbf29ce484222325u64;
for part in parts {
for byte in part.as_bytes() {
hash ^= u64::from(*byte);
hash = hash.wrapping_mul(0x100000001b3);
}
hash ^= 0xff;
hash = hash.wrapping_mul(0x100000001b3);
}
format!("motion:{hash:016x}")
}
pub mod prelude {
pub use crate::{
BorrowedMotionTargetRef, DurationDx, Motion, MotionArtifactCache, MotionBaselineReport,
MotionBatchRequest, MotionBudget, MotionCompatibilityRow, MotionCurve, MotionDiagnostic,
MotionDiagnosticLevel, MotionDiagnosticVerbosity, MotionDoctorReport, MotionExplainReport,
MotionGraph, MotionGroup, MotionGrp, MotionIntegrationPolicy, MotionIntegrationTarget,
MotionKeyframe, MotionKf, MotionManifestFragment, MotionMemoryCache, MotionOffloadPlan,
MotionPlayback, MotionPolicyDecision, MotionPresetProfile, MotionReducedMotionPolicy,
MotionRenderLane, MotionRouteOverride, MotionSerializationFormat, MotionTargetRef,
MotionTraceEvent, MotionTrack, MotionTrackKind, MotionTrigger, aggressive_motion_group,
apply_motion_policy_hook, balanced_motion_group, batch_motion_manifest,
compact_motion_dictionary, conservative_motion_group, duration_ms_u16, duration_ms_u32,
explain_motion_integration, keyframe, motion, motion_baseline_report, motion_cache_key,
motion_compatibility_matrix, motion_css_custom_properties, motion_doctor, motion_group,
motion_manifest_fragment, motion_manifest_fragment_with_trace, motion_target_ref_borrowed,
motion_workertown_offload_plan, process_motion_batch, serialize_motion_fragment,
};
}
pub mod dx {
pub use crate::{DurationDx, duration_ms_u16, duration_ms_u32};
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum MotionValidationError {
DuplicateGroupId(String),
DuplicateTrackId(String),
MissingTrackGroup { track: String, group: String },
}
pub fn validate_motion_graph(graph: &MotionGraph) -> Result<(), MotionValidationError> {
let mut group_ids = BTreeSet::new();
for group in &graph.groups {
if !group_ids.insert(group.id.clone()) {
return Err(MotionValidationError::DuplicateGroupId(group.id.clone()));
}
}
let mut track_ids = BTreeSet::new();
for track in &graph.tracks {
if !track_ids.insert(track.id.clone()) {
return Err(MotionValidationError::DuplicateTrackId(track.id.clone()));
}
if !group_ids.contains(&track.group) {
return Err(MotionValidationError::MissingTrackGroup {
track: track.id.clone(),
group: track.group.clone(),
});
}
}
Ok(())
}
fn clamp_unit(value: f32) -> f32 {
if value.is_nan() {
0.0
} else {
value.clamp(0.0, 1.0)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn validates_deterministic_group_and_track_ids() {
let graph = MotionGraph::new("demo")
.with_group(MotionGroup::new("intro"))
.with_track(MotionTrack::new(
"card",
"intro",
MotionTrackKind::MorphFramePlan,
MotionTargetRef::new("card", "morph"),
));
assert!(graph.validate().is_ok());
let duplicate = graph.clone().with_group(MotionGroup::new("intro"));
assert_eq!(
duplicate.validate(),
Err(MotionValidationError::DuplicateGroupId("intro".to_string()))
);
let missing = MotionGraph::new("demo").with_track(MotionTrack::new(
"orphan",
"missing",
MotionTrackKind::ElementKeyframes,
MotionTargetRef::new("orphan", "timeline"),
));
assert_eq!(
missing.validate(),
Err(MotionValidationError::MissingTrackGroup {
track: "orphan".to_string(),
group: "missing".to_string()
})
);
}
#[test]
fn serializes_stable_motion_shape() {
let graph = MotionGraph::new("demo")
.with_group(
MotionGroup::new("intro")
.with_trigger(MotionTrigger::Visible)
.with_render_lane(MotionRenderLane::MorphFramePlan),
)
.with_track(
MotionTrack::new(
"hero",
"intro",
MotionTrackKind::MorphFramePlan,
MotionTargetRef::new("hero", "morph"),
)
.with_keyframes([
MotionKeyframe::new(1.0).with_value("progress", serde_json::json!(1)),
MotionKeyframe::new(0.0).with_value("progress", serde_json::json!(0)),
]),
);
let json = graph.to_json().expect("graph serializes");
assert!(json.contains(r#""id":"demo""#));
assert!(json.contains(r#""trigger":"visible""#));
assert!(json.contains(r#""renderLanes":["morph-frame-plan"]"#));
assert!(json.find(r#""offset":0.0"#).unwrap() < json.find(r#""offset":1.0"#).unwrap());
}
#[test]
fn duration_dx_helpers_support_unsuffixed_literals() {
assert_eq!(140.ms().as_millis(), 140);
assert_eq!(140u64.ms().as_millis(), 140);
assert_eq!(140usize.ms().as_millis(), 140);
assert_eq!(1.s().as_secs(), 1);
assert_eq!(2.s().as_secs(), 2);
assert_eq!((-5).ms().as_millis(), 0);
}
#[test]
fn integration_policy_emits_stable_route_manifest_fragments() {
let graph = MotionGraph::new("demo")
.with_group(
MotionGroup::new("intro")
.with_render_lane(MotionRenderLane::ViewTransition)
.with_render_lane(MotionRenderLane::TextFx),
)
.with_track(MotionTrack::new(
"hero",
"intro",
MotionTrackKind::ElementKeyframes,
MotionTargetRef::new("hero", "viewtx"),
));
let policy = MotionIntegrationPolicy::new(MotionIntegrationTarget::ViewTx)
.profile(MotionPresetProfile::Conservative)
.route_override(
MotionRouteOverride::new("/admin/*")
.enabled(false)
.label("owner", "platform")
.tag("internal"),
)
.tag("visual");
let public = motion_manifest_fragment(&graph, &policy, "/");
let admin = motion_manifest_fragment(&graph, &policy, "/admin/home");
assert!(public.enabled);
assert!(!admin.enabled);
assert_eq!(public.target, "dioxus-viewtx");
assert_eq!(
public.cache_key,
motion_cache_key(&graph, &policy, Some("/"))
);
assert!(
public
.capabilities
.contains(&"motion.lane.textfx".to_string())
);
assert_eq!(
admin.labels.get("owner").map(String::as_str),
Some("platform")
);
assert!(serialize_motion_fragment(&public, MotionSerializationFormat::Json).is_ok());
}
#[test]
fn explain_doctor_and_cache_backend_cover_diagnostics() {
let graph = MotionGraph::new("demo").with_track(MotionTrack::new(
"orphan",
"missing",
MotionTrackKind::Custom,
MotionTargetRef::new("orphan", "custom"),
));
let policy = MotionIntegrationPolicy::new(MotionIntegrationTarget::Custom(
"dioxus-dxr-cli".to_string(),
));
let report = explain_motion_integration(&graph, &policy, "/broken");
let doctor = motion_doctor(&graph, &policy);
let mut cache = MotionMemoryCache::default();
assert!(!doctor.ok);
assert!(report.to_text().contains("motion.validation"));
cache.insert(report.cache_key.clone(), report.to_text());
assert!(
cache
.get(&report.cache_key)
.unwrap()
.contains("target: dioxus-dxr-cli")
);
}
#[test]
fn presets_borrowed_targets_and_dictionary_are_deterministic() {
let group = aggressive_motion_group("hero");
let target = motion_target_ref_borrowed("card", "timeline")
.selector("[data-card]")
.into_owned();
let graph = MotionGraph::new("demo")
.with_group(group)
.with_track(MotionTrack::new(
"card",
"hero",
MotionTrackKind::ElementKeyframes,
target,
));
assert!(graph.groups[0].duration_ms > DEFAULT_MOTION_DURATION_MS);
assert_eq!(
graph.tracks[0].target.selector.as_deref(),
Some("[data-card]")
);
assert_eq!(
compact_motion_dictionary(&graph).get("card").copied(),
Some(0)
);
assert_eq!(motion_compatibility_matrix().len(), 4);
assert!(motion_css_custom_properties(&graph.groups[0]).contains("--dxmotion-duration"));
}
#[test]
fn offload_trace_batch_and_baseline_reports_are_serializable() {
let graph = MotionGraph::new("demo")
.with_group(
MotionGroup::new("hero").with_render_lane(MotionRenderLane::WorkerTownRender),
)
.with_track(
MotionTrack::new(
"card",
"hero",
MotionTrackKind::ElementKeyframes,
MotionTargetRef::new("card", "motion"),
)
.with_keyframes([MotionKeyframe::new(0.0), MotionKeyframe::new(1.0)]),
);
let policy = MotionIntegrationPolicy::new(MotionIntegrationTarget::WorkerTownCore);
let offload = motion_workertown_offload_plan(&graph, &policy, "/route");
let mut events = Vec::new();
let traced = motion_manifest_fragment_with_trace(&graph, &policy, "/route", |event| {
events.push(event.clone())
});
let batch =
MotionBatchRequest::new("/route", vec![graph.clone()]).deterministic_parallel(true);
let fragments = process_motion_batch(&batch, &policy);
let baseline = motion_baseline_report(&graph);
assert!(offload.serializable);
assert_eq!(offload.worker_task, "motion.prepare-fragment");
assert_eq!(events[0].decision, "emit");
assert_eq!(traced.cache_key, fragments[0].cache_key);
assert_eq!(baseline.group_count, 1);
assert_eq!(baseline.track_count, 1);
assert_eq!(baseline.keyframe_count, 2);
assert!(baseline.estimated_operations >= 4);
}
}