# Motion: agent spec

Twin of `/motion`. Source wins: `app/assets/css/main.css`, `app/utils/motion-tokens.ts`.

## Families

| Family | What animates | How |
|---|---|---|
| Interaction | A state change on something already on screen: hover, press, toggle, colour | CSS transition, `--ease-standard`, `--duration-fast` or `--duration-base` |
| Entrance | Something that was not there: a slide, a section scrolled into view | `motion-v` spring from `motion-tokens.ts` |

## CSS tokens (`main.css`)

| Token | Value |
|---|---|
| `--duration-fast` | 150ms: hover, press, toggle |
| `--duration-base` | 250ms: a longer move |
| `--ease-standard` | `cubic-bezier(0.22, 1, 0.36, 1)` (ease-out quint) |

No `--duration-slow`. Add one only for a real case.

## JS tokens (`motion-tokens.ts`)

| Token | Value | Use |
|---|---|---|
| `durationFast` | `0.15` | `--duration-fast` |
| `durationBase` | `0.25` | `--duration-base` |
| `easeStandard` | `[0.22, 1, 0.36, 1]` | `--ease-standard` |
| `springLogoUnroll` | `{ type: 'spring', visualDuration: 1.1, bounce: 0.2 }` | Logo entrance (`useUnroll`) only |
| `springLogoRelease` | `{ type: 'spring', visualDuration: 0.3, bounce: 0.35 }` | Logo light release (`LogoMarkStage`) only |
| `springBase` | `{ type: 'spring', visualDuration: 0.3, bounce: 0 }` | Repeatable: slide change, panel |
| `springReveal` | `{ type: 'spring', visualDuration: 0.35, bounce: 0 }` | Once: scroll reveal |
| `revealVariants.rise` | `opacity 0→1`, `y 20→0` | Section header |
| `revealVariants.fade` | `opacity 0→1` | Section body: prose, tables, charts, cards |
| `revealVariantsReduced` | `revealVariants.fade` | Reveal under reduced motion |
| `staggerStep` | `0.1` | Step between staged parts. `ReportReveal`'s `delay` counts in steps. |

Change a value in `main.css` first, then mirror it in `motion-tokens.ts`.

## Where motion runs

| Motion | Where | Value |
|---|---|---|
| Colour | Links, `ThemeSwitch`, nav items, `LayoutGrid` controls | `transition-colors`, `--duration-fast` |
| Press | `.press-feedback`, `BrandButton` | `scale: 0.96`, `--duration-fast` |
| Menu icon | `MobileBar` bars to a cross | `transform`, `opacity`, `--duration-fast` |
| Bar hides | `MobileBar`: up on scroll down past 54px, back on scroll up (4px slack) | `translate`, `--duration-base` |
| Logo unroll | `LogoMark intro="load"` / `Wordmark intro` (bars, once per visit, 0.2s delay), `LogoMark intro="view"` (`SiteFooter`), `LogoMarkStage` (50% in view) | `springLogoUnroll` |
| Logo light | `LogoMarkStage` drag release | `springLogoRelease` |
| Reveal | Section title `rise`, body `fade` | `springReveal`, `staggerStep` |
| Loop | `SiteCarousel`: `auto-scroll` speed 0.6, stops on hover, Pause / Play, off under reduced motion | Embla |
| Loader | `LogoLoader`, `/page-loader` only | own timeline (below) |

## `/motion` demos

Abstract: circles in ink on a 3:2 grey tile (`MotionFrame`), never the logo or a real component. Caption: title left, the toggle right (Play / Reverse; the loop Play / Pause). Nothing plays on its own.

Every demo is a CSS transition on a toggled state, so it reverses and can be interrupted at any point. A spring becomes a transition through `cssSpring()` (`motion-tokens.ts`): motion's `spring().toString()`, a duration and a `linear()` curve. The loop is a CSS animation (`@keyframes motion-demo-loop`) toggled by `animation-play-state`. Under reduced motion no demo travels or scales; fades and colour stay.

| `kind` | On / off |
|---|---|
| `unroll` | Two circles from `-400%` above, `cssSpring(springLogoUnroll)`, the second `staggerStep * 2` later. Starts off (empty). |
| `release` | Out: `translate -120%` over `--duration-base * 2`, `--ease-standard`. Back: `cssSpring(springLogoRelease)`. |
| `colour` | 15 circles, dimmed ↔ ink, `--duration-fast`, a diagonal 40ms wave. |
| `press` | Five circles, `scale` 1 ↔ 0.96, `--duration-fast`, `staggerStep` apart. |
| `grow` | `scale` 1 ↔ 0.5, `--duration-base`. |
| `reveal` | 15 circles, `rise` (opacity 0, `y` 20px) ↔ shown, `cssSpring(springReveal)`, the same wave. Starts off (empty). |
| `loop` | A row of circles, 24s linear cycle, edges masked. |

## Logo exceptions

`springLogoUnroll` and `springLogoRelease` bounce. Do not use them outside the logo. The unroll does not run under reduced motion.

`LogoLoader` has its own curves (ease-out quart, ease-in-out cubic), 3.5s, and no reduced-motion handling. Demo only on `/page-loader`. It is not the site's loader.

`LogoLoader` timeline (`TIMELINE`, seconds):

| Phase | Span | Curve |
|---|---|---|
| `power`: the horizon line snaps on | `0.35` to `0.5` | linear |
| `sun`: the M rises, the stage goes from 90° to 45° | `0.45` to `3.2` | ease-out quart |
| Day: ground to surface, stage white to ink | second half of `sun` (`DAY_FROM: 0.5`) | ease-in-out cubic |
| `end` | `3.5` | |

Colours: B&W, Bright. The M rises `360` units. Horizon line `4` units.

## Report (parked): view transitions (`ReportDeck.vue`)

| Value | |
|---|---|
| Enter offset | `x` 24px, in the direction of travel |
| Exit offset | `x` 16px, opposite |
| Deck entrance stagger | `staggerChildren: durationFast` |
| Spring | `springBase` |

Forward enters from the right and exits left. Back reverses both. Resolve direction with dynamic variants plus `custom`, not an inline `exit`.

## Rules

1. Pick the family first. Interaction: CSS transition. Entrance: spring from `motion-tokens.ts`.
2. Never use a library animation for a press or hover. Never use a keyframe for a state change.
3. Never type a duration, curve or spring into a `:transition`. Use a token.
4. Springs take `visualDuration` and `bounce`. Never `duration`, `stiffness`, `damping` or `mass`.
5. `bounce: 0` on every spring, except the logo exceptions above.
6. Press feedback: `scale: 0.96` on `:active`, over `--duration-fast`. Never below `0.95`. Use `.press-feedback` (`main.css`, unlayered so it beats `UButton`'s `transition-colors`).
7. High-frequency interactions: ≤150ms, `opacity` or colour only.
8. Never `transition: all`. Name each property.
9. A section header rises (`rise`). Its body fades (`fade`). No blur.
10. Do not nest reveals. Body components (`::report-stat`, `::report-callout`, `::report-figure`, `::report-timeline`, `::report-rank`) have none.
11. Reveals fire once and run in both report modes. Add one through `ReportReveal` (`::report-reveal`).
12. Reduced motion: CSS motion goes in `@media (prefers-reduced-motion: no-preference)` or `motion-safe:`. `<MotionConfig reduced-motion="user">` (`app.vue`) covers library transforms but still applies their end value. Zero travel with `useReducedMotion()` where the end value would jump (`ReportDeck`).
13. Unovis charts ignore `MotionConfig`. Pass `duration: 0` under `useReducedMotion()`.
14. Motion is never the only signal. A static cue (colour, icon, label) carries the state.
15. Content is visible without motion. `.report-reveal` is forced visible in `@media print` and under `html:not(.js)` (`main.css`; `nuxt.config.ts` adds `js`). Never put a fallback `<style>` in a `<noscript>` in a Vue template: Vue renders it live.
16. A component that defers its render until in view (charts) renders at once while `useReportMode().printing` is set.

## In-view trigger

- motion-v's `whileInView` fires for every element at hydration (motion-v 2.4). Do not use it.
- motion-v auto-imports its own `useInView`, which shadows `app/composables/useInView.ts`. An unqualified call gets motion-v's. `ReportReveal`, `ChartBar` and `ChartLine` use motion-v's.
- To use ours (it takes `{ target, options }` and enforces `threshold`), import it by path: `import { useInView } from '~/composables/useInView'` (`LogoMarkStage`).
- Ours is `true` on the server, fires once, then disconnects. Default options: `{ rootMargin: '0px 0px -10% 0px' }`.

## Craft: `motion-framer`

1. Animate transforms and `opacity` only. Never `top`, `left`, `width`, `height`, `margin`.
2. Orchestrate from a shared parent (`variants` + `staggerChildren`). Hand delays only with no shared parent.
3. A child either inherits the parent's states or declares `animate`. Never both.
4. Put a gesture's transition inside the gesture object (`whileTap: { scale, transition }`).
5. `AnimatePresence`: the exiting element is a direct child with a unique `key`.
6. No `layout` or `layoutId`.
7. Suppress a mount animation with `initial={false}` (`AnimatePresence :initial="false"` in the deck).

## Related

- `/agent/layout.md`: spacing and rounding tokens.
- `/agent/typography.md`: type tokens.
