# ANIMATION_SPEC.md
Technical realisation of the Phase 1 motion system (MOTION_GUIDELINES + ANIMATION_CONCEPTS A1–A10). Library decision: **none** (TECH_SPEC §4) — CSS-first + the shared observer module.

---

## 1. Token implementation

Motion tokens are CSS custom properties (`--dur-instant: 80ms` … `--dur-signature: 700ms`, `--ease-out`, `--ease-in-out`) defined once in `motion.css`; every transition/keyframe references tokens. A single `@media (prefers-reduced-motion: reduce)` layer overrides: durations → 1ms (not 0 — ensures transitionend semantics still fire), translations → none, trace/pulse keyframes → final frame. No per-component reduced-motion logic anywhere.

## 2. Concept → mechanism map

| Concept (Phase 1) | Mechanism | Trigger |
|---|---|---|
| A1 Trace draw | Inline SVG, `stroke-dasharray/dashoffset` keyframe, dot `opacity` blink ×1 | CSS animation on load; `sessionStorage` flag adds `data-seen` on repeat visits → static |
| A2 Section arrive | `[data-reveal]` class toggle → `opacity` + `translateY(12px)` transition; children staggered via `--reveal-index` custom property delay | `observer.ts`, threshold 0.2, once |
| A3 Card focus | Pure CSS `:hover`/`:focus-visible` (border token, transform 4px, underline scale, arrow translate) | none (CSS) |
| A4 Chip pulse | CSS keyframe ×2 iterations on `.status-active .dot`, started when `[data-reveal]` fires (animation-play-state pattern) | observer |
| A5 Diagram flow | SVG with class-annotated nodes/edges; `:hover` on node → CSS sibling/descendant rules highlight edges, dim others via `opacity` | CSS only, `@media (hover: hover)` gate |
| A6 Theme morph | Two-path SVG icon crossfade/morph via CSS; global colour transition on `data-theme` swap limited to a curated property list (avoid transitioning *everything*) | `theme.ts` |
| A7 Language cross-fade | View Transitions API (same-document for the switch control; cross-document for navigation) with CSS-only fallback = normal navigation | browser-native, progressive |
| A8 Timeline grow | Spine is a scaleY-transformed pseudo-element; dots opacity-staggered | observer |
| A9 Form confirm | Panel swap with height-locked container; check icon `stroke-dashoffset` draw | `form.ts` on 2xx |
| A10 404 fray | Static SVG with a short CSS keyframe (dash gap growth); reduced-motion static | load |
| Header compress | `data-scrolled` boolean flag at >120px (observer sentinel element) → CSS height/blur transition. *Deviation from Phase 1:* linear scroll-mapping downgraded to a threshold toggle with smooth transition — visually near-identical, avoids scroll-linked JS entirely; scroll-driven CSS animations may restore true mapping as progressive enhancement where supported | observer |

## 3. Performance rules (enforceable)

- Animatable properties allow-list: `transform`, `opacity`, `stroke-dashoffset`, plus theme-swap colour transitions. Anything else fails review.
- No `will-change` except trace + lightbox transitions (added/removed around use).
- Observer instances: exactly one shared IntersectionObserver for reveals/spy + one sentinel for header. No scroll event listeners anywhere.
- Lighthouse TBT budget (<100 ms) is the regression tripwire for animation JS.

## 4. Accessibility rules

- Reduced-motion layer per §1 — verified in Playwright with emulated `prefers-reduced-motion`.
- Nothing flashes >3×/s (chip pulse is 2 iterations, ~1 Hz). No parallax beyond 4px.
- Focus is never moved by animation; reveals never contain focusable elements while hidden (visibility handled so tab order is stable pre-reveal).
- View-transition language swap must not discard focus position: switch control retains focus after navigation (tested).
