autumn-plugin-motion 0.2.0

Autumn plugin: Motion (motion.dev) animations with Maud + htmx ergonomics.
Documentation
# autumn-plugin-motion

Motion ([motion.dev](https://motion.dev), vanilla JS, MIT) animations for
Autumn apps, with Maud + htmx ergonomics. No npm, no bundler, no React: the
plugin vendors the Motion UMD build into the crate and serves it from
memory, and gives you a typed Rust builder plus a declarative
`data-motion` attribute system.

## Quickstart

Add the plugin:

```rust
use autumn_plugin_motion::MotionPlugin;

autumn_web::app()
    .plugin(MotionPlugin::new())
    .run()
    .await;
```

Put the scripts in your layout (both are `defer`red and SRI-hashed):

```rust
use autumn_plugin_motion::motion_script;
use autumn_web::{Markup, html};

fn layout(content: Markup) -> Markup {
    html! {
        html {
            head { (motion_script()) }
            body { (content) }
        }
    }
}
```

Animate with the typed builder — it wraps your markup in an animated
`<div>`, no attribute splicing:

```rust
use autumn_plugin_motion::Motion;

let page = layout(html! {
    (Motion::fade_up().delay(100).wrap(html! {
        h1 { "Hello" }
    }))
});
```

Or write the attributes directly on your own elements:

```rust
let list = html! {
    ul data-motion="fade-up" data-motion-stagger="60" {
        li { "one" }
        li { "two" }
        li { "three" }
    }
};
```

## htmx

This is where it shines. The init script re-scans on `htmx:afterSwap`,
scoped to the swap target, so every htmx partial animates declaratively
with zero extra JS:

```rust
#[get("/feed")]
async fn feed() -> Markup {
    html! {
        div id="feed" {
            // Each row fades up, staggered — including rows added later
            // by an htmx swap into #feed.
            (Motion::fade_up().stagger(50).wrap(rows()))
        }
    }
}
```

```html
<button hx-get="/feed" hx-target="#feed" hx-swap="afterbegin">
    Load more
</button>
```

Scroll-linked effects work the same way:

```rust
(Motion::fade().scroll().wrap(html! {
    div class="scrub-me" { }
}))
```

Target another element's traversal, or remap the offsets:

```rust
// Fade as #hero crosses the viewport; finish when the element's center
// hits the viewport's center.
(Motion::fade()
    .scroll_target("#hero")
    .scroll_offset(["start end", "center center"])
    .wrap(markup))
```

Parallax drift and the opinionated progress bar:

```rust
use autumn_plugin_motion::{Motion, motion_stylesheet};

// In <head>, once per page:
(motion_stylesheet())

// Hero art that lags the scroll:
(Motion::parallax(0.3).wrap(markup))

// A fixed top bar that fills with page scroll — zero hand-written JS:
(Motion::scroll_progress())
```

## Demo

`examples/motion_demo.rs` is a tiny runnable app showing all of it:

```sh
cargo run --example motion_demo
# then open http://127.0.0.1:3000
```

The page demonstrates a staggered hero entrance with parallax drift,
scroll-reveal cards (one scrubbed by the scroll position itself), an
htmx "Load more" button whose server-rendered batches stagger in via the
`htmx:afterSwap` re-scan hook, and a scroll-driven progress bar
(`Motion::scroll_progress()` — one line, zero hand-written JS).

## Gotchas

Learned the hard way while building the demo — the rules for JS/CSS in
Autumn apps:

- **Never inline `<script>`.** The default CSP is `script-src 'self'` (no
  `unsafe-inline`), so inline scripts are blocked. Put JS in external
  files under the app's `static/` dir and reference them with
  `asset_url("js/app.js")`.
- **Never inline `<style>` either.** In nonce mode
  (`security.headers.csp_nonce`) `style-src` becomes `'self'` + nonce
  only, so inline styles break. Same answer: external stylesheet via
  `asset_url("css/app.css")`.
- **htmx ships with the framework.** Don't pull it from a CDN:
  `asset_url("js/htmx.min.js")` resolves to Autumn's built-in embedded
  htmx handler — no vendoring needed (it's skipped automatically if you
  pin your own copy with `autumn assets add htmx@…`).
- **Embed the app's `static/` dir** with `autumn_web::embed_static!()`
  and hand it to `AppBuilder::embedded_static(&STATIC)`; `$CARGO_MANIFEST_DIR`
  is the crate root even for examples, so this works from `examples/` too.

## Attribute reference

| Attribute | Values | Default | Notes |
|---|---|---|---|
| `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` | `fade-up` | The preset. Unknown values fall back to `fade-up`. `parallax` and `scroll-progress` are special scroll-driven kinds (see below), not entrance presets. |
| `data-motion-delay` | milliseconds, e.g. `150` | `0` | Start delay. |
| `data-motion-duration` | seconds, e.g. `1.2` | `0.6` | Animation duration. |
| `data-motion-once` | `false` | _(once)_ | Set to `false` to re-animate every time the element enters the viewport. |
| `data-motion-stagger` | milliseconds, e.g. `60` | — | On a container: cascade the preset over its direct children. |
| `data-motion-stagger-from` | `first`, `last`, `center`, `edges`, or a child index, e.g. `2` | `first` | Where the staggered cascade starts. Only applies with `data-motion-stagger`. |
| `data-motion-stagger-ease` | same syntax as `data-motion-ease` | — | Ease the stagger distribution across children. |
| `data-motion-hover` | e.g. `scale(1.05) brightness(1.1)` | — | Grow and/or brighten while hovered; eases back on leave. |
| `data-motion-press` | e.g. `scale(0.95)` | — | Shrink while pressed; releases on pointer up. |
| `data-motion-reduced` | `animate` | — | Opt back into animation when the user prefers reduced motion. Default: skip animation, content stays fully visible. |
| `data-motion-scroll` | _(bare attribute)_ | — | Drive the keyframes by scroll progress instead of animating on entry. |
| `data-motion-scroll-target` | CSS selector, e.g. `#hero` | _(the element itself)_ | Drive the scroll-linked keyframes by another element's traversal. Implies `data-motion-scroll`. |
| `data-motion-scroll-offset` | comma-separated edges, e.g. `start end,center center` | `start end,end start` | Remap the scroll-linked keyframes onto custom viewport edges. Invalid entries are ignored. |
| `data-motion-parallax` | factor, e.g. `0.3` | `0.3` | With `data-motion="parallax"`: scroll-linked drift of `factor` × the element's own height. Positive lags the scroll; negative opposes it. |
| `data-motion-ease` | `linear`, `ease-in`, `ease-out`, `ease-in-out`, `circ-in`, `circ-out`, `circ-in-out`, `back-in`, `back-out`, `back-in-out`, `anticipate`, `cubic-bezier(0.16,1,0.3,1)`, `spring(300,20,1)` | `cubic-bezier(0.16,1,0.3,1)` | Easing curve. `spring(stiffness,damping,mass)` uses spring physics. |
| `data-motion-repeat` | times, e.g. `2` | — | Repeat the animation `n` times after the first play. |
| `data-motion-repeat-type` | `loop`, `reverse`, `mirror` | `loop` | How each repeat cycle restarts. |
| `data-motion-amount` | `some`, `all`, or a fraction `0`–`1`, e.g. `0.5` | `some` | How much of the element must be visible before the `inView` trigger fires. |
| `data-motion-margin` | CSS margin syntax in `px`/`%`, e.g. `-100px`, `80px` | — | Grow/shrink the viewport for the `inView` trigger. Negative waits until the element is further inside; positive fires early. Invalid values are ignored. |

The Rust [`Motion`](https://docs.rs/autumn-plugin-motion) builder mirrors
all of these: `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()`, plus `.delay(ms)`, `.duration(secs)`,
`.ease(Ease::Out)`, `.repeat(n)`, `.repeat_type(RepeatType::Mirror)`,
`.amount(0.5)` (or `.amount(InViewAmount::All)`), `.margin("-100px")`,
`.scroll_target("#hero")`, `.scroll_offset(["start end", "center center"])`,
`.stagger_from(StaggerFrom::Center)`, `.stagger_ease(Ease::Out)`,
`.hover_scale(1.05)`, `.hover_brightness(1.1)`, `.press_scale(0.97)`,
`.once(bool)`, `.stagger(ms)`, `.scroll()`, and `.wrap(markup)`.
`Motion::parallax(0.3)` builds a scroll-drift wrapper and
`Motion::scroll_progress()` renders the opinionated progress-bar element
(pair it with `motion_stylesheet()` in `<head>`). The
[`Ease`] enum covers named easings, `Ease::CubicBezier(x1, y1, x2, y2)`,
and `Ease::Spring { stiffness, damping, mass }`:

```rust
use autumn_plugin_motion::{Ease, Motion};

let bouncy = Motion::scale()
    .ease(Ease::Spring { stiffness: 300.0, damping: 20.0, mass: 1.0 })
    .wrap(markup);
```

Details:

- Elements animate when they scroll into view (`inView`), not on page
  load — below-the-fold content waits its turn.
- The scanner marks handled elements with `data-motion-init`, so htmx
  re-scans never double-animate.
- `prefers-reduced-motion: reduce` disables all animations; content stays
  fully visible. A single element can opt back in with
  `data-motion-reduced="animate"`. If Motion fails to load, the page is
  likewise untouched — animations are progressive enhancement, never a
  dependency.
- Stagger containers animate their *direct children*; don't put
  `data-motion` on the children too.

## How it works

- `assets/motion.min.js` — the upstream minified Motion UMD build
  (12.43.0, from jsDelivr), renamed from `dist/motion.js`. Sets
  `window.Motion`.
- `assets/init.js` — the plugin-authored scanner. Reads the attributes
  above, calls `Motion.animate` / `inView` / `scroll` / `stagger` /
  `hover` / `press`.
- `assets/motion.css` — default styles for `Motion::scroll_progress()`.
- `assets/manifest.json` — vendoring provenance: Motion version pin,
  source URL, and the `sha384` of the upstream file. Not served.
- The three served files form one `PluginAssets` bundle
  (`MOTION_ASSETS`), which `MotionPlugin` installs with Autumn's
  `AppBuilder::plugin_assets` seam (autumn-web 0.8+). The framework
  serves each file under `/static/_plugins/motion/`:
  - at a content-hashed URL (`init.<sha256-prefix>.js`) with
    `Cache-Control: public, max-age=31536000, immutable`, and
  - at its plain URL (`init.js`) with `must-revalidate`,
  - both with `ETag` / `304` and `Range` support.
- `motion_script()` / `motion_stylesheet()` emit the hashed URLs with
  `integrity=` hashes the framework computes from the embedded bytes —
  no hand-kept SRI constants. The routes show up in `autumn routes` as
  public, plugin-attributed static files.
- Need a URL yourself? `MOTION_ASSETS.url("init.js")`, or
  `asset_url("_plugins/motion/init.js")` once the plugin is installed.

## Upgrading from 0.1

0.2 requires autumn-web 0.8 and moves the assets onto the framework's
plugin-asset seam:

- Asset URLs moved from `/__motion/*` to `/static/_plugins/motion/*`
  (content-hashed). If you only use `motion_script()` /
  `motion_stylesheet()`, nothing changes for you. Hand-written
  `/__motion/...` references must switch to those helpers,
  `MOTION_ASSETS.url(..)`, or `asset_url("_plugins/motion/..")`.
- `motion_routes()` is gone — `MotionPlugin` installs `MOTION_ASSETS`.
- `INIT_JS_INTEGRITY` and `MOTION_CSS_INTEGRITY` are gone — use
  `MOTION_ASSETS.integrity("init.js")` / `("motion.css")`.
  `MOTION_JS_INTEGRITY` stays as the upstream provenance pin.
- The plugin no longer turns on autumn-web's `embed-assets` feature;
  enable it in your app if you rely on it.

## Limits

- **Motion+ is not included.** The paid motion.dev tier (`splitText`,
  `Cursor`, `Carousel`, `Ticker`, `AnimateNumber`, ScrambleText,
  Typewriter, …) needs a private-registry token and is deliberately out
  of scope — this plugin ships the MIT core only.
- **The Motion version is pinned per plugin release** (see
  `assets/manifest.json`). Upgrading Motion means upgrading the plugin;
  there is no `autumn assets`-style version switch.
- **Declarative enter/scroll effects only.** No layout animations, no
  shared-element transitions, no gesture handlers in the attribute
  layer — for bespoke choreography, call `window.Motion` directly; the
  global is there.
- **No live-browser animation verification.** The init script's logic is
  covered by content/string tests only (hook presence, preset table,
  reduced-motion guard). Motion's own test suite covers the animation
  engine; this plugin covers the wiring.