# 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).