Skip to main content

autumn_plugin_motion/
motion.rs

1//! The typed [`Motion`] animation builder.
2//!
3//! A [`Motion`] value describes one declarative animation: a preset plus
4//! modifiers. It renders the `data-motion*` attributes that the plugin's
5//! `init.js` scans for, so animations stay type-checked on the Rust side
6//! while the behavior lives in the vendored Motion build.
7//!
8//! # Raw attribute reference
9//!
10//! The builder emits these attributes; you can also write them by hand on
11//! your own elements:
12//!
13//! - `data-motion="fade-up|fade-down|fade-left|fade-right|fade|scale|zoom-in|zoom-out|slide-left|slide-right|slide-up|slide-down|rotate-in|blur-in"`
14//!   — the preset (default `fade-up`).
15//! - `data-motion-delay="150"` — start delay in milliseconds (default `0`).
16//! - `data-motion-duration="0.6"` — duration in seconds (default `0.6`).
17//! - `data-motion-once="false"` — re-animate every time the element enters
18//!   the viewport (default is once).
19//! - `data-motion-stagger="60"` — on a container: cascade the preset over
20//!   its direct children, `60` ms apart.
21//! - `data-motion-scroll` — bare attribute: drive the keyframes by scroll
22//!   progress instead of animating on entry.
23//! - `data-motion-ease="ease-out|cubic-bezier(0.16,1,0.3,1)|spring(300,20,1)"`
24//!   — easing curve (default: a snappy `cubic-bezier(0.16,1,0.3,1)`).
25//! - `data-motion-repeat="2"` — repeat the animation `n` times after the
26//!   first play.
27//! - `data-motion-repeat-type="loop|reverse|mirror"` — how each repeat
28//!   cycle restarts (default `loop`).
29//! - `data-motion-amount="some|all|0.5"` — how much of the element must be
30//!   visible before the `inView` trigger fires (default `some`).
31//! - `data-motion-margin="-100px"` — grow/shrink the viewport for the
32//!   `inView` trigger, in CSS margin syntax (`px` or `%`, up to four
33//!   values). Negative waits until the element is further inside;
34//!   positive fires early.
35//! - `data-motion="parallax"` + `data-motion-parallax="0.3"` — scroll-linked
36//!   drift: the element travels `0.3` × its own height as it moves through
37//!   the viewport (see [`Motion::parallax`]).
38//! - `data-motion="scroll-progress"` — opinionated page-scroll progress bar
39//!   (see [`Motion::scroll_progress`]).
40//! - `data-motion-scroll-target="#hero"` — drive the scroll-linked keyframes
41//!   by *another* element's traversal (a CSS selector; falls back to the
42//!   element itself when missing). Implies `data-motion-scroll`.
43//! - `data-motion-scroll-offset="start end,center center"` — comma-separated
44//!   scroll offsets mapping target/container edges (default
45//!   `"start end,end start"`). Invalid entries are ignored.
46//! - `data-motion-reduced="animate"` — opt back into animation when the
47//!   user prefers reduced motion (default: skip animation, content stays
48//!   fully visible).
49//! - `data-motion-stagger-from="first|last|center|edges|2"` — where a
50//!   staggered cascade starts (default `first`). Only applies with
51//!   `data-motion-stagger`.
52//! - `data-motion-stagger-ease="ease-out"` — ease the stagger distribution
53//!   across children (same value syntax as `data-motion-ease`).
54//! - `data-motion-hover="scale(1.05) brightness(1.1)"` — grow and/or
55//!   brighten on hover (any subset).
56//! - `data-motion-press="scale(0.95)"` — shrink while pressed.
57
58use autumn_web::{Markup, html};
59
60/// Default animation duration in seconds, used when no duration is set.
61const DEFAULT_DURATION_SECS: f32 = 0.6;
62
63/// An easing curve for a [`Motion`] animation.
64///
65/// Serializes to the `data-motion-ease` attribute, which `assets/init.js`
66/// maps onto Motion's easing definitions. When no ease is set, `init.js`
67/// applies a snappy expo-out curve (`cubic-bezier(0.16, 1, 0.3, 1)`).
68///
69/// ```rust
70/// use autumn_plugin_motion::{Ease, Motion};
71/// use autumn_web::{Markup, html};
72///
73/// let card: Markup = Motion::scale()
74///     .ease(Ease::Spring { stiffness: 300.0, damping: 20.0, mass: 1.0 })
75///     .wrap(html! { p { "Boing" } });
76/// assert!(card.into_string().contains(r#"data-motion-ease="spring(300,20,1)""#));
77/// ```
78#[derive(Debug, Clone, Copy, PartialEq)]
79pub enum Ease {
80    /// Constant speed.
81    Linear,
82    /// Start slow, end fast (`cubic-bezier(0.42, 0, 1, 1)`).
83    In,
84    /// Start fast, end slow (`cubic-bezier(0, 0, 0.58, 1)`).
85    Out,
86    /// Slow at both ends (`cubic-bezier(0.42, 0, 0.58, 1)`).
87    InOut,
88    /// Circular ease-in.
89    CircIn,
90    /// Circular ease-out.
91    CircOut,
92    /// Circular ease-in-out.
93    CircInOut,
94    /// Overshoots, then settles (ease-in flavor).
95    BackIn,
96    /// Overshoots, then settles (ease-out flavor).
97    BackOut,
98    /// Overshoots, then settles (in-out flavor).
99    BackInOut,
100    /// Starts by moving slightly backwards, then accelerates.
101    Anticipate,
102    /// A custom cubic-bezier curve: `(x1, y1, x2, y2)`.
103    CubicBezier(f32, f32, f32, f32),
104    /// Spring physics. `stiffness` is the spring strength, `damping` the
105    /// friction, `mass` the weight being moved.
106    Spring {
107        /// Spring strength (higher = snappier).
108        stiffness: f32,
109        /// Friction (higher = less oscillation).
110        damping: f32,
111        /// Weight being moved.
112        mass: f32,
113    },
114}
115
116impl Ease {
117    /// The `data-motion-ease` attribute value for this ease.
118    fn attr_value(self) -> String {
119        match self {
120            Self::Linear => "linear".to_owned(),
121            Self::In => "ease-in".to_owned(),
122            Self::Out => "ease-out".to_owned(),
123            Self::InOut => "ease-in-out".to_owned(),
124            Self::CircIn => "circ-in".to_owned(),
125            Self::CircOut => "circ-out".to_owned(),
126            Self::CircInOut => "circ-in-out".to_owned(),
127            Self::BackIn => "back-in".to_owned(),
128            Self::BackOut => "back-out".to_owned(),
129            Self::BackInOut => "back-in-out".to_owned(),
130            Self::Anticipate => "anticipate".to_owned(),
131            Self::CubicBezier(x1, y1, x2, y2) => {
132                format!("cubic-bezier({x1},{y1},{x2},{y2})")
133            }
134            Self::Spring {
135                stiffness,
136                damping,
137                mass,
138            } => format!("spring({stiffness},{damping},{mass})"),
139        }
140    }
141}
142
143/// How a repeated [`Motion`] animation restarts each cycle.
144///
145/// Serializes to the `data-motion-repeat-type` attribute; mirrors Motion's
146/// `repeatType` transition option.
147#[derive(Debug, Clone, Copy, PartialEq, Eq)]
148pub enum RepeatType {
149    /// Jump back to the start each cycle (default).
150    Loop,
151    /// Play backwards on alternate cycles.
152    Reverse,
153    /// Alternate direction each cycle (ping-pong).
154    Mirror,
155}
156
157impl RepeatType {
158    /// The `data-motion-repeat-type` attribute value.
159    const fn attr(self) -> &'static str {
160        match self {
161            Self::Loop => "loop",
162            Self::Reverse => "reverse",
163            Self::Mirror => "mirror",
164        }
165    }
166}
167
168/// How much of the element must be visible before its `inView` animation
169/// triggers.
170///
171/// Serializes to the `data-motion-amount` attribute; mirrors Motion's
172/// `inView` `amount` option (`"some" | "all" | number`).
173#[derive(Debug, Clone, Copy, PartialEq)]
174pub enum InViewAmount {
175    /// Trigger when any part of the element enters the viewport
176    /// (Motion's default).
177    Some,
178    /// Trigger only when the whole element is visible.
179    All,
180    /// Trigger when this fraction (0.0–1.0) of the element is visible.
181    /// Values outside the range are clamped.
182    Fraction(f32),
183}
184
185impl InViewAmount {
186    /// The `data-motion-amount` attribute value.
187    fn attr_value(self) -> String {
188        match self {
189            Self::Some => "some".to_string(),
190            Self::All => "all".to_string(),
191            Self::Fraction(v) => format!("{}", v.clamp(0.0, 1.0)),
192        }
193    }
194}
195
196impl From<f32> for InViewAmount {
197    /// Lets `.amount(0.5)` mean `.amount(InViewAmount::Fraction(0.5))`.
198    fn from(v: f32) -> Self {
199        Self::Fraction(v)
200    }
201}
202
203/// Where a staggered cascade starts.
204///
205/// Serializes to `data-motion-stagger-from`; mirrors Motion's `stagger`
206/// `from` option (`"first" | "last" | "center" | "edges" | index`).
207#[derive(Debug, Clone, Copy, PartialEq, Eq)]
208pub enum StaggerFrom {
209    /// From the first child (default).
210    First,
211    /// From the last child.
212    Last,
213    /// From the middle outward.
214    Center,
215    /// From the edges inward.
216    Edges,
217    /// From the child at this index.
218    Index(u32),
219}
220
221impl StaggerFrom {
222    /// The `data-motion-stagger-from` attribute value.
223    fn attr_value(self) -> String {
224        match self {
225            Self::First => "first".to_string(),
226            Self::Last => "last".to_string(),
227            Self::Center => "center".to_string(),
228            Self::Edges => "edges".to_string(),
229            Self::Index(i) => i.to_string(),
230        }
231    }
232}
233
234/// An animation preset, mirroring the keyframes in `assets/init.js`.
235#[derive(Debug, Clone, Copy, PartialEq, Eq)]
236pub enum Preset {
237    /// Fade in while rising 24px.
238    FadeUp,
239    /// Fade in while sinking 24px.
240    FadeDown,
241    /// Fade in while drifting 24px left.
242    FadeLeft,
243    /// Fade in while drifting 24px right.
244    FadeRight,
245    /// Fade in place.
246    Fade,
247    /// Fade in while scaling from 0.94.
248    Scale,
249    /// Fade in while zooming from 0.8 scale.
250    ZoomIn,
251    /// Fade in while settling from 1.15 scale.
252    ZoomOut,
253    /// Fade in while sliding 32px from the right.
254    SlideLeft,
255    /// Fade in while sliding 32px from the left.
256    SlideRight,
257    /// Fade in while rising 32px from below.
258    SlideUp,
259    /// Fade in while sinking 32px from above.
260    SlideDown,
261    /// Fade in while straightening from -8 degrees.
262    RotateIn,
263    /// Fade in while de-blurring from 8px.
264    BlurIn,
265    /// Scroll-linked drift (see [`Motion::parallax`]).
266    Parallax,
267}
268
269impl Preset {
270    /// The `data-motion` attribute value for this preset.
271    const fn attr(self) -> &'static str {
272        match self {
273            Self::FadeUp => "fade-up",
274            Self::FadeDown => "fade-down",
275            Self::FadeLeft => "fade-left",
276            Self::FadeRight => "fade-right",
277            Self::Fade => "fade",
278            Self::Scale => "scale",
279            Self::ZoomIn => "zoom-in",
280            Self::ZoomOut => "zoom-out",
281            Self::SlideLeft => "slide-left",
282            Self::SlideRight => "slide-right",
283            Self::SlideUp => "slide-up",
284            Self::SlideDown => "slide-down",
285            Self::RotateIn => "rotate-in",
286            Self::BlurIn => "blur-in",
287            Self::Parallax => "parallax",
288        }
289    }
290}
291
292/// A declarative animation: preset plus modifiers.
293///
294/// Build with a preset constructor ([`Motion::fade_up`], [`Motion::fade`],
295/// [`Motion::scale`], [`Motion::slide_left`], … — see [`Preset`]),
296/// tune with the modifier methods, then either [`Motion::wrap`] the markup
297/// or write the attributes by hand (see the module docs).
298///
299/// ```rust
300/// use autumn_plugin_motion::Motion;
301/// use autumn_web::{Markup, html};
302///
303/// let card: Markup = Motion::fade_up().delay(150).wrap(html! {
304///     h2 { "Hello" }
305/// });
306/// assert!(card.into_string().contains(r#"data-motion="fade-up""#));
307/// ```
308#[derive(Debug, Clone)]
309pub struct Motion {
310    preset: Preset,
311    delay_ms: u32,
312    duration_secs: Option<f32>,
313    ease: Option<Ease>,
314    repeat: Option<u32>,
315    repeat_type: Option<RepeatType>,
316    amount: Option<InViewAmount>,
317    margin: Option<String>,
318    scroll_target: Option<String>,
319    scroll_offset: Option<[String; 2]>,
320    parallax_factor: Option<f32>,
321    stagger_from: Option<StaggerFrom>,
322    stagger_ease: Option<Ease>,
323    hover_scale: Option<f32>,
324    hover_brightness: Option<f32>,
325    press_scale: Option<f32>,
326    once: bool,
327    stagger_ms: Option<u32>,
328    scroll: bool,
329}
330
331impl Motion {
332    /// Fade in while rising 24px.
333    #[must_use]
334    pub const fn fade_up() -> Self {
335        Self::preset(Preset::FadeUp)
336    }
337
338    /// Fade in while sinking 24px.
339    #[must_use]
340    pub const fn fade_down() -> Self {
341        Self::preset(Preset::FadeDown)
342    }
343
344    /// Fade in while drifting 24px left.
345    #[must_use]
346    pub const fn fade_left() -> Self {
347        Self::preset(Preset::FadeLeft)
348    }
349
350    /// Fade in while drifting 24px right.
351    #[must_use]
352    pub const fn fade_right() -> Self {
353        Self::preset(Preset::FadeRight)
354    }
355
356    /// Fade in place.
357    #[must_use]
358    pub const fn fade() -> Self {
359        Self::preset(Preset::Fade)
360    }
361
362    /// Fade in while scaling from 0.94.
363    #[must_use]
364    pub const fn scale() -> Self {
365        Self::preset(Preset::Scale)
366    }
367
368    /// Fade in while zooming from 0.8 scale.
369    #[must_use]
370    pub const fn zoom_in() -> Self {
371        Self::preset(Preset::ZoomIn)
372    }
373
374    /// Fade in while settling from 1.15 scale.
375    #[must_use]
376    pub const fn zoom_out() -> Self {
377        Self::preset(Preset::ZoomOut)
378    }
379
380    /// Fade in while sliding 32px from the right.
381    #[must_use]
382    pub const fn slide_left() -> Self {
383        Self::preset(Preset::SlideLeft)
384    }
385
386    /// Fade in while sliding 32px from the left.
387    #[must_use]
388    pub const fn slide_right() -> Self {
389        Self::preset(Preset::SlideRight)
390    }
391
392    /// Fade in while rising 32px from below.
393    #[must_use]
394    pub const fn slide_up() -> Self {
395        Self::preset(Preset::SlideUp)
396    }
397
398    /// Fade in while sinking 32px from above.
399    #[must_use]
400    pub const fn slide_down() -> Self {
401        Self::preset(Preset::SlideDown)
402    }
403
404    /// Fade in while straightening from -8 degrees.
405    #[must_use]
406    pub const fn rotate_in() -> Self {
407        Self::preset(Preset::RotateIn)
408    }
409
410    /// Fade in while de-blurring from 8px.
411    #[must_use]
412    pub const fn blur_in() -> Self {
413        Self::preset(Preset::BlurIn)
414    }
415
416    /// Scroll-linked drift for hero art and backgrounds.
417    ///
418    /// As the element travels through the viewport, it translates by
419    /// `factor` × its own height: positive lags the scroll (drifts down as
420    /// you scroll down), negative moves against it. `0.3` is a good
421    /// default; keep `|factor|` modest — large values fling content away.
422    ///
423    /// Parallax is inherently scroll-linked and ignores the `scroll`,
424    /// `scroll_target`, and `scroll_offset` modifiers.
425    ///
426    /// ```rust
427    /// use autumn_plugin_motion::Motion;
428    /// use autumn_web::{Markup, html};
429    ///
430    /// let html: Markup = Motion::parallax(0.3).wrap(html! { p { "Hi" } });
431    /// let s = html.into_string();
432    /// assert!(s.contains(r#"data-motion="parallax""#), "{s}");
433    /// assert!(s.contains(r#"data-motion-parallax="0.3""#), "{s}");
434    /// ```
435    #[must_use]
436    pub const fn parallax(factor: f32) -> Self {
437        let mut motion = Self::preset(Preset::Parallax);
438        motion.parallax_factor = Some(factor);
439        motion
440    }
441
442    /// Opinionated page-scroll progress bar element.
443    ///
444    /// Renders a fixed top bar that fills left-to-right as the page
445    /// scrolls — no hand-written JavaScript. Include the plugin stylesheet
446    /// once per page for the default styling:
447    ///
448    /// ```rust
449    /// use autumn_plugin_motion::{Motion, motion_stylesheet};
450    /// use autumn_web::{Markup, html};
451    ///
452    /// let head: Markup = html! { (motion_stylesheet()) };
453    /// assert!(head.into_string().contains("/static/_plugins/motion/motion."));
454    /// let bar: Markup = Motion::scroll_progress();
455    /// let s = bar.into_string();
456    /// assert!(s.contains(r#"data-motion="scroll-progress""#), "{s}");
457    /// assert!(s.contains(r#"class="motion-progress""#), "{s}");
458    /// ```
459    #[must_use]
460    pub fn scroll_progress() -> Markup {
461        html! {
462            div class="motion-progress" data-motion="scroll-progress" {}
463        }
464    }
465
466    /// Builds a [`Motion`] from a raw preset.
467    const fn preset(preset: Preset) -> Self {
468        Self {
469            preset,
470            delay_ms: 0,
471            duration_secs: None,
472            ease: None,
473            repeat: None,
474            repeat_type: None,
475            amount: None,
476            margin: None,
477            scroll_target: None,
478            scroll_offset: None,
479            parallax_factor: None,
480            stagger_from: None,
481            stagger_ease: None,
482            hover_scale: None,
483            hover_brightness: None,
484            press_scale: None,
485            once: true,
486            stagger_ms: None,
487            scroll: false,
488        }
489    }
490
491    /// Start delay in milliseconds.
492    #[must_use]
493    pub const fn delay(mut self, ms: u32) -> Self {
494        self.delay_ms = ms;
495        self
496    }
497
498    /// Duration in seconds (default 0.6).
499    #[must_use]
500    pub const fn duration(mut self, secs: f32) -> Self {
501        self.duration_secs = Some(secs);
502        self
503    }
504
505    /// Easing curve (default: a snappy `cubic-bezier(0.16,1,0.3,1)` applied
506    /// by `init.js` when the attribute is absent).
507    ///
508    /// ```rust
509    /// use autumn_plugin_motion::{Ease, Motion};
510    /// use autumn_web::{Markup, html};
511    ///
512    /// let html: Markup = Motion::fade_up()
513    ///     .ease(Ease::Out)
514    ///     .wrap(html! { p { "Hi" } });
515    /// assert!(html.into_string().contains(r#"data-motion-ease="ease-out""#));
516    /// ```
517    #[must_use]
518    pub const fn ease(mut self, ease: Ease) -> Self {
519        self.ease = Some(ease);
520        self
521    }
522
523    /// Repeat the animation `n` times after the first play (so `repeat(2)`
524    /// plays three times total). Useful for pulsing badges and attention
525    /// loops.
526    ///
527    /// ```rust
528    /// use autumn_plugin_motion::Motion;
529    /// use autumn_web::{Markup, html};
530    ///
531    /// let html: Markup = Motion::scale().repeat(2).wrap(html! { p { "Hi" } });
532    /// assert!(html.into_string().contains(r#"data-motion-repeat="2""#));
533    /// ```
534    #[must_use]
535    pub const fn repeat(mut self, n: u32) -> Self {
536        self.repeat = Some(n);
537        self
538    }
539
540    /// How each repeat cycle restarts: [`RepeatType::Loop`] (default),
541    /// [`RepeatType::Reverse`], or [`RepeatType::Mirror`] (ping-pong).
542    #[must_use]
543    pub const fn repeat_type(mut self, repeat_type: RepeatType) -> Self {
544        self.repeat_type = Some(repeat_type);
545        self
546    }
547
548    /// How much of the element must be visible before the `inView`
549    /// animation triggers: [`InViewAmount::Some`] (default),
550    /// [`InViewAmount::All`], or [`InViewAmount::Fraction`]. A bare `f32`
551    /// works too, via `From`:
552    ///
553    /// ```rust
554    /// use autumn_plugin_motion::{InViewAmount, Motion};
555    /// use autumn_web::{Markup, html};
556    ///
557    /// let a: Markup = Motion::fade_up().amount(0.5).wrap(html! { p { "Hi" } });
558    /// assert!(a.into_string().contains(r#"data-motion-amount="0.5""#));
559    /// let b: Markup = Motion::fade_up()
560    ///     .amount(InViewAmount::All)
561    ///     .wrap(html! { p { "Hi" } });
562    /// assert!(b.into_string().contains(r#"data-motion-amount="all""#));
563    /// ```
564    #[must_use]
565    pub fn amount(mut self, amount: impl Into<InViewAmount>) -> Self {
566        self.amount = Some(amount.into());
567        self
568    }
569
570    /// Grow or shrink the viewport used for the `inView` trigger, in CSS
571    /// margin syntax with `px` or `%` values — e.g. `"-100px"` waits until
572    /// the element is 100px inside the viewport, `"80px"` fires early.
573    /// Up to four space-separated values, like CSS `margin`.
574    ///
575    /// ```rust
576    /// use autumn_plugin_motion::Motion;
577    /// use autumn_web::{Markup, html};
578    ///
579    /// let html: Markup = Motion::fade_up().margin("-100px").wrap(html! { p { "Hi" } });
580    /// assert!(html.into_string().contains(r#"data-motion-margin="-100px""#));
581    /// ```
582    #[must_use]
583    pub fn margin(mut self, margin: impl Into<String>) -> Self {
584        self.margin = Some(margin.into());
585        self
586    }
587
588    /// When `false`, re-animate every time the element enters the viewport
589    /// instead of only the first time.
590    #[must_use]
591    pub const fn once(mut self, once: bool) -> Self {
592        self.once = once;
593        self
594    }
595
596    /// Cascade the preset over the wrapped element's direct children, `ms`
597    /// milliseconds apart.
598    #[must_use]
599    pub const fn stagger(mut self, ms: u32) -> Self {
600        self.stagger_ms = Some(ms);
601        self
602    }
603
604    /// Where the staggered cascade starts: [`StaggerFrom::First`] (default),
605    /// [`StaggerFrom::Last`], [`StaggerFrom::Center`],
606    /// [`StaggerFrom::Edges`], or [`StaggerFrom::Index`]. Only applies with
607    /// [`.stagger(ms)`](Motion::stagger).
608    ///
609    /// ```rust
610    /// use autumn_plugin_motion::{Motion, StaggerFrom};
611    /// use autumn_web::{Markup, html};
612    ///
613    /// let html: Markup = Motion::fade_up()
614    ///     .stagger(90)
615    ///     .stagger_from(StaggerFrom::Center)
616    ///     .wrap(html! { p { "Hi" } });
617    /// assert!(
618    ///     html.into_string().contains(r#"data-motion-stagger-from="center""#)
619    /// );
620    /// ```
621    #[must_use]
622    pub const fn stagger_from(mut self, from: StaggerFrom) -> Self {
623        self.stagger_from = Some(from);
624        self
625    }
626
627    /// Ease the stagger distribution across children — e.g. [`Ease::Out`]
628    /// bunches later children together. Same value syntax as
629    /// [`.ease()`](Motion::ease); only applies with
630    /// [`.stagger(ms)`](Motion::stagger).
631    #[must_use]
632    pub const fn stagger_ease(mut self, ease: Ease) -> Self {
633        self.stagger_ease = Some(ease);
634        self
635    }
636
637    /// Drive the keyframes by scroll progress as the element travels through
638    /// the viewport, instead of animating on entry.
639    #[must_use]
640    pub const fn scroll(mut self) -> Self {
641        self.scroll = true;
642        self
643    }
644
645    /// Drive the scroll-linked keyframes by *another* element's traversal
646    /// of the viewport — a CSS selector like `"#hero"`. When the selector
647    /// matches nothing, the element itself is used.
648    ///
649    /// Implies [`Motion::scroll`].
650    ///
651    /// ```rust
652    /// use autumn_plugin_motion::Motion;
653    /// use autumn_web::{Markup, html};
654    ///
655    /// let html: Markup = Motion::fade()
656    ///     .scroll_target("#hero")
657    ///     .wrap(html! { p { "Hi" } });
658    /// let s = html.into_string();
659    /// assert!(s.contains(r#"data-motion-scroll-target="#), "{s}");
660    /// assert!(s.contains("#hero"), "{s}");
661    /// assert!(s.contains("data-motion-scroll"), "{s}");
662    /// ```
663    #[must_use]
664    pub fn scroll_target(mut self, selector: impl Into<String>) -> Self {
665        self.scroll_target = Some(selector.into());
666        self.scroll = true;
667        self
668    }
669
670    /// Remap the scroll-linked keyframes onto custom viewport edges, e.g.
671    /// `["start end", "center center"]` finishes the animation when the
672    /// element's center reaches the viewport's center (default
673    /// `["start end", "end start"]`: the full traversal).
674    ///
675    /// Each entry names a target edge and a container edge
676    /// (`start`/`center`/`end`, or a number with optional `px`/`%`).
677    /// Implies [`Motion::scroll`].
678    ///
679    /// ```rust
680    /// use autumn_plugin_motion::Motion;
681    /// use autumn_web::{Markup, html};
682    ///
683    /// let html: Markup = Motion::fade()
684    ///     .scroll_offset(["start end", "center center"])
685    ///     .wrap(html! { p { "Hi" } });
686    /// assert!(
687    ///     html.into_string().contains(
688    ///         r#"data-motion-scroll-offset="start end,center center""#
689    ///     )
690    /// );
691    /// ```
692    #[must_use]
693    pub fn scroll_offset(mut self, offset: [&str; 2]) -> Self {
694        self.scroll_offset = Some([offset[0].into(), offset[1].into()]);
695        self.scroll = true;
696        self
697    }
698
699    /// Grow to `scale` while hovered (e.g. `1.05` for a subtle lift).
700    /// The element eases back on pointer leave. Pairs with entrance
701    /// presets; combines with [`Motion::hover_brightness`].
702    ///
703    /// ```rust
704    /// use autumn_plugin_motion::Motion;
705    /// use autumn_web::{Markup, html};
706    ///
707    /// let html: Markup = Motion::fade_up()
708    ///     .hover_scale(1.05)
709    ///     .hover_brightness(1.1)
710    ///     .wrap(html! { p { "Hi" } });
711    /// let s = html.into_string();
712    /// assert!(s.contains(r#"data-motion-hover="scale(1.05) brightness(1.1)""#), "{s}");
713    /// ```
714    #[must_use]
715    pub const fn hover_scale(mut self, scale: f32) -> Self {
716        self.hover_scale = Some(scale);
717        self
718    }
719
720    /// Brighten to `brightness` while hovered (e.g. `1.1`). Combines with
721    /// [`Motion::hover_scale`].
722    #[must_use]
723    pub const fn hover_brightness(mut self, brightness: f32) -> Self {
724        self.hover_brightness = Some(brightness);
725        self
726    }
727
728    /// Shrink to `scale` while pressed (e.g. `0.97` for a tactile button).
729    /// Releases back on pointer up.
730    ///
731    /// ```rust
732    /// use autumn_plugin_motion::Motion;
733    /// use autumn_web::{Markup, html};
734    ///
735    /// let html: Markup = Motion::fade_up().press_scale(0.97).wrap(html! { p { "Hi" } });
736    /// assert!(
737    ///     html.into_string().contains(r#"data-motion-press="scale(0.97)""#)
738    /// );
739    /// ```
740    #[must_use]
741    pub const fn press_scale(mut self, scale: f32) -> Self {
742        self.press_scale = Some(scale);
743        self
744    }
745
746    /// Wraps `markup` in a `<div>` carrying the animation attributes.
747    ///
748    /// This is the Maud-friendly composition path — no attribute splicing,
749    /// just a wrapper element:
750    ///
751    /// ```rust
752    /// use autumn_plugin_motion::Motion;
753    /// use autumn_web::html;
754    ///
755    /// let page = html! {
756    ///     (Motion::fade_up().delay(100).wrap(html! { p { "Hi" } }))
757    /// };
758    /// ```
759    ///
760    /// Takes `markup` by value even though Maud only borrows it: the owned
761    /// form is the ergonomic call shape (`.wrap(html! { ... })`).
762    #[allow(clippy::needless_pass_by_value)]
763    #[must_use]
764    pub fn wrap(self, markup: Markup) -> Markup {
765        let preset = self.preset.attr();
766        let delay = self.delay_ms.ne(&0).then(|| self.delay_ms.to_string());
767        let duration = self
768            .duration_secs
769            .filter(|d| *d != DEFAULT_DURATION_SECS)
770            .map(|d| d.to_string());
771        let once = (!self.once).then_some("false");
772        let stagger = self.stagger_ms.map(|ms| ms.to_string());
773        let scroll = self.scroll;
774        let ease = self.ease.map(Ease::attr_value);
775        let repeat = self.repeat.map(|n| n.to_string());
776        let repeat_type = self.repeat_type.map(RepeatType::attr);
777        let amount = self.amount.map(InViewAmount::attr_value);
778        let margin = self.margin;
779        let scroll_target = self.scroll_target;
780        let scroll_offset = self.scroll_offset.map(|[a, b]| format!("{a},{b}"));
781        let parallax = self.parallax_factor.map(|f| format!("{f}"));
782        let stagger_from = self.stagger_from.map(StaggerFrom::attr_value);
783        let stagger_ease = self.stagger_ease.map(Ease::attr_value);
784        let mut hover_parts = Vec::new();
785        if let Some(scale) = self.hover_scale {
786            hover_parts.push(format!("scale({scale})"));
787        }
788        if let Some(brightness) = self.hover_brightness {
789            hover_parts.push(format!("brightness({brightness})"));
790        }
791        let hover = (!hover_parts.is_empty()).then(|| hover_parts.join(" "));
792        let press = self.press_scale.map(|scale| format!("scale({scale})"));
793        html! {
794            div data-motion=(preset)
795                data-motion-delay=[delay.as_deref()]
796                data-motion-duration=[duration.as_deref()]
797                data-motion-ease=[ease.as_deref()]
798                data-motion-repeat=[repeat.as_deref()]
799                data-motion-repeat-type=[repeat_type]
800                data-motion-amount=[amount.as_deref()]
801                data-motion-margin=[margin.as_deref()]
802                data-motion-parallax=[parallax.as_deref()]
803                data-motion-scroll-target=[scroll_target.as_deref()]
804                data-motion-scroll-offset=[scroll_offset.as_deref()]
805                data-motion-stagger-from=[stagger_from.as_deref()]
806                data-motion-stagger-ease=[stagger_ease.as_deref()]
807                data-motion-hover=[hover.as_deref()]
808                data-motion-press=[press.as_deref()]
809                data-motion-once=[once]
810                data-motion-stagger=[stagger.as_deref()]
811                data-motion-scroll[scroll]
812            {
813                (markup)
814            }
815        }
816    }
817}
818
819#[cfg(test)]
820mod tests {
821    use super::*;
822
823    /// Renders a [`Motion`] wrapping empty markup.
824    fn render(motion: Motion) -> String {
825        motion.wrap(html! {}).into_string()
826    }
827
828    #[test]
829    fn all_presets_render_their_attribute() {
830        let cases = [
831            (Motion::fade_up(), "fade-up"),
832            (Motion::fade_down(), "fade-down"),
833            (Motion::fade_left(), "fade-left"),
834            (Motion::fade_right(), "fade-right"),
835            (Motion::fade(), "fade"),
836            (Motion::scale(), "scale"),
837            (Motion::zoom_in(), "zoom-in"),
838            (Motion::zoom_out(), "zoom-out"),
839            (Motion::slide_left(), "slide-left"),
840            (Motion::slide_right(), "slide-right"),
841            (Motion::slide_up(), "slide-up"),
842            (Motion::slide_down(), "slide-down"),
843            (Motion::rotate_in(), "rotate-in"),
844            (Motion::blur_in(), "blur-in"),
845        ];
846        for (motion, attr) in cases {
847            let html = render(motion);
848            assert!(
849                html.contains(&format!(r#"data-motion="{attr}""#)),
850                "preset renders data-motion=\"{attr}\": {html}"
851            );
852        }
853    }
854
855    #[test]
856    fn defaults_omit_optional_attributes() {
857        let html = render(Motion::fade_up());
858        for attr in [
859            "data-motion-delay",
860            "data-motion-duration",
861            "data-motion-ease",
862            "data-motion-repeat",
863            "data-motion-amount",
864            "data-motion-margin",
865            "data-motion-parallax",
866            "data-motion-scroll-target",
867            "data-motion-scroll-offset",
868            "data-motion-stagger-from",
869            "data-motion-stagger-ease",
870            "data-motion-hover",
871            "data-motion-press",
872            "data-motion-once",
873            "data-motion-stagger",
874            "data-motion-scroll",
875        ] {
876            assert!(!html.contains(attr), "default omits {attr}: {html}");
877        }
878    }
879
880    #[test]
881    fn modifiers_render_their_attributes() {
882        let html = render(
883            Motion::scale()
884                .delay(150)
885                .duration(1.2)
886                .once(false)
887                .stagger(60),
888        );
889        assert!(html.contains(r#"data-motion-delay="150""#), "{html}");
890        assert!(html.contains(r#"data-motion-duration="1.2""#), "{html}");
891        assert!(html.contains(r#"data-motion-once="false""#), "{html}");
892        assert!(html.contains(r#"data-motion-stagger="60""#), "{html}");
893    }
894
895    #[test]
896    fn default_duration_is_omitted() {
897        let html = render(Motion::fade().duration(DEFAULT_DURATION_SECS));
898        assert!(
899            !html.contains("data-motion-duration"),
900            "explicit default duration is omitted: {html}"
901        );
902    }
903
904    #[test]
905    fn ease_renders_its_attribute() {
906        let cases = [
907            (Ease::Linear, r#"data-motion-ease="linear""#),
908            (Ease::Out, r#"data-motion-ease="ease-out""#),
909            (Ease::InOut, r#"data-motion-ease="ease-in-out""#),
910            (Ease::CircOut, r#"data-motion-ease="circ-out""#),
911            (Ease::BackIn, r#"data-motion-ease="back-in""#),
912            (Ease::Anticipate, r#"data-motion-ease="anticipate""#),
913            (
914                Ease::CubicBezier(0.16, 1.0, 0.3, 1.0),
915                r#"data-motion-ease="cubic-bezier(0.16,1,0.3,1)""#,
916            ),
917            (
918                Ease::Spring {
919                    stiffness: 300.0,
920                    damping: 20.0,
921                    mass: 1.0,
922                },
923                r#"data-motion-ease="spring(300,20,1)""#,
924            ),
925        ];
926        for (ease, attr) in cases {
927            let html = render(Motion::fade_up().ease(ease));
928            assert!(html.contains(attr), "ease renders {attr}: {html}");
929        }
930    }
931
932    #[test]
933    fn repeat_renders_its_attributes() {
934        let html = render(Motion::scale().repeat(2).repeat_type(RepeatType::Mirror));
935        assert!(html.contains(r#"data-motion-repeat="2""#), "{html}");
936        assert!(
937            html.contains(r#"data-motion-repeat-type="mirror""#),
938            "{html}"
939        );
940    }
941
942    #[test]
943    fn amount_variants_render() {
944        let html = render(Motion::fade_up().amount(0.5));
945        assert!(html.contains(r#"data-motion-amount="0.5""#), "{html}");
946        let html = render(Motion::fade_up().amount(InViewAmount::All));
947        assert!(html.contains(r#"data-motion-amount="all""#), "{html}");
948        let html = render(Motion::fade_up().amount(InViewAmount::Some));
949        assert!(html.contains(r#"data-motion-amount="some""#), "{html}");
950    }
951
952    #[test]
953    fn amount_fraction_clamps_to_unit_range() {
954        let html = render(Motion::fade_up().amount(1.5));
955        assert!(html.contains(r#"data-motion-amount="1""#), "{html}");
956        let html = render(Motion::fade_up().amount(-0.2));
957        assert!(html.contains(r#"data-motion-amount="0""#), "{html}");
958    }
959
960    #[test]
961    fn margin_renders() {
962        let html = render(Motion::fade_up().margin("-100px"));
963        assert!(html.contains(r#"data-motion-margin="-100px""#), "{html}");
964    }
965
966    #[test]
967    fn parallax_renders_kind_and_factor() {
968        let html = render(Motion::parallax(0.3));
969        assert!(html.contains(r#"data-motion="parallax""#), "{html}");
970        assert!(html.contains(r#"data-motion-parallax="0.3""#), "{html}");
971    }
972
973    #[test]
974    fn scroll_target_renders_and_implies_scroll() {
975        let html = render(Motion::fade().scroll_target("#hero"));
976        assert!(html.contains("data-motion-scroll-target"), "{html}");
977        assert!(html.contains("#hero"), "{html}");
978        assert!(html.contains("data-motion-scroll"), "{html}");
979    }
980
981    #[test]
982    fn scroll_offset_renders_comma_separated() {
983        let html = render(Motion::fade().scroll_offset(["start end", "center center"]));
984        assert!(
985            html.contains(r#"data-motion-scroll-offset="start end,center center""#),
986            "{html}"
987        );
988    }
989
990    #[test]
991    fn scroll_progress_renders_opinionated_bar() {
992        let html = Motion::scroll_progress().into_string();
993        assert!(html.contains(r#"data-motion="scroll-progress""#), "{html}");
994        assert!(html.contains(r#"class="motion-progress""#), "{html}");
995    }
996
997    #[test]
998    fn stagger_from_and_ease_render() {
999        let html = render(
1000            Motion::fade_up()
1001                .stagger(90)
1002                .stagger_from(StaggerFrom::Center)
1003                .stagger_ease(Ease::Out),
1004        );
1005        assert!(html.contains(r#"data-motion-stagger="90""#), "{html}");
1006        assert!(
1007            html.contains(r#"data-motion-stagger-from="center""#),
1008            "{html}"
1009        );
1010        assert!(
1011            html.contains(r#"data-motion-stagger-ease="ease-out""#),
1012            "{html}"
1013        );
1014    }
1015
1016    #[test]
1017    fn stagger_from_index_renders_number() {
1018        let html = render(Motion::fade_up().stagger_from(StaggerFrom::Index(2)));
1019        assert!(html.contains(r#"data-motion-stagger-from="2""#), "{html}");
1020    }
1021
1022    #[test]
1023    fn hover_and_press_render() {
1024        let html = render(
1025            Motion::fade_up()
1026                .hover_scale(1.05)
1027                .hover_brightness(1.1)
1028                .press_scale(0.97),
1029        );
1030        assert!(
1031            html.contains(r#"data-motion-hover="scale(1.05) brightness(1.1)""#),
1032            "{html}"
1033        );
1034        assert!(
1035            html.contains(r#"data-motion-press="scale(0.97)""#),
1036            "{html}"
1037        );
1038    }
1039
1040    #[test]
1041    fn scroll_renders_bare_attribute() {
1042        let html = render(Motion::fade_up().scroll());
1043        assert!(
1044            html.contains("data-motion-scroll"),
1045            "scroll renders the bare attribute: {html}"
1046        );
1047    }
1048
1049    #[test]
1050    fn wrap_keeps_inner_markup() {
1051        let html = Motion::fade_up()
1052            .wrap(html! { p { "inner text" } })
1053            .into_string();
1054        assert!(html.starts_with("<div"), "{html}");
1055        assert!(html.contains("<p>inner text</p>"), "{html}");
1056        assert!(html.contains(r#"data-motion="fade-up""#), "{html}");
1057    }
1058}