diff --git a/docs/motion-enhancement-plan.md b/docs/motion-enhancement-plan.md new file mode 100644 index 000000000..a20004c09 --- /dev/null +++ b/docs/motion-enhancement-plan.md @@ -0,0 +1,950 @@ +# Motion & Interactivity Enhancement Plan — Raven Design System + +> **Status:** Draft for team review. No code changes yet. +> **Branch:** `add/animation-station` +> **Compliance target:** WCAG 2.1 AA (floor) + AODA. AAA where cheap to hit. +> **Last updated:** 2026-05-11 + +This plan proposes a cohesive motion-and-interactivity layer for RDS. It is an _enhancement_ pass, not a redesign: existing aesthetics are preserved and no component visual identity changes. Every animation must degrade gracefully under `prefers-reduced-motion: reduce`, must not trap focus, and must use GPU-friendly properties (`transform` / `opacity`). + +--- + +## Table of Contents + +1. [Goals & Guiding Principles](#1-goals--guiding-principles) +2. [Current-State Assessment](#2-current-state-assessment) +3. [Cross-Cutting Infrastructure (lands first)](#3-cross-cutting-infrastructure-lands-first) +4. [Component Inventory](#4-component-inventory) +5. [Per-Component Enhancement Plan](#5-per-component-enhancement-plan) +6. [New Component Candidates](#6-new-component-candidates) +7. [Testing & Verification](#7-testing--verification) +8. [Phased Roadmap](#8-phased-roadmap) +9. [Open Questions for the Team](#9-open-questions-for-the-team) + +--- + +## 1. Goals & Guiding Principles + +### What we want + +- **Subtle, purposeful motion.** Motion communicates state, hierarchy, and relationship. It is never decoration for its own sake. +- **A unified motion vocabulary.** Durations, easings, and patterns are tokenised so the whole system feels coherent. +- **Accessibility-first by default.** Every animation has a reduced-motion answer. No motion blocks interaction or focus. +- **Performance-first.** Animate `transform` and `opacity` only. No layout thrash. Hooks tree-shake and add < 1 KB gzipped per use. +- **No new heavy dependencies.** No Framer Motion, no GSAP. CSS + tiny custom hooks only. (RDS already has bundle-size concerns flagged in `docs/IMPROVEMENTS.md`.) + +### Non-goals + +- Wholesale visual redesign. +- Decorative parallax, hover-tilt, "wow" effects. +- Auto-playing motion without user control. +- Motion that runs longer than 5 seconds without a pause control (WCAG 2.2.2 _Pause, Stop, Hide_). +- Motion that delivers information by movement alone (WCAG 1.4.13 / 2.3.x). + +### Hard rules (apply universally) + +| # | Rule | Why | +| --- | ------------------------------------------------------------------------------------------------------------------- | --------------------------- | +| H1 | Every `transition-*` / `animate-*` class is paired with a `motion-reduce:` override or wrapped in `motion-safe:` | WCAG 2.3.3 / user safety | +| H2 | Any JS-driven animation reads `prefers-reduced-motion` via `useReducedMotion` and skips/instant-completes when true | WCAG 2.3.3 | +| H3 | Animate `transform` / `opacity` only, never `top`/`left`/`width`/`height` | Performance, jank avoidance | +| H4 | Durations 150–500 ms, except progress fills (≤ 1500 ms) and skeletons (≥ 1500 ms loop) | Perceptual sweet spot | +| H5 | Focus is never gated on animation completion. Focus moves immediately; motion is decorative on top. | WCAG 2.4.7 | +| H6 | Auto-playing motion ≥ 5 s requires a user-controllable pause | WCAG 2.2.2 | +| H7 | No parallax, no scroll-jacking, no carousel auto-advance without explicit opt-in + pause | WCAG, AODA | +| H8 | Storybook story per animated state (idle, hover, focus, active, open/closed, reduced-motion) | Verifiability | + +--- + +## 2. Current-State Assessment + +### 2.1 Motion already in the codebase + +| Component | Motion present | File:line | Notes | +| -------------------------------------- | -------------------------------------------------- | --------------------------------------------------------------------------- | ---------------------------------------------------------------- | +| Card | hover scale (1.02) + shadow | `lib/components/Card/Card.tsx:45` | `duration-300 ease-in`. No `motion-reduce` | +| Toast | slide-in + fade, JS removal after 200 ms | `lib/components/Toast/Toast.tsx:48,57` | Hard-coded timeout couples JS to CSS | +| Nav | header hide-on-scroll | `lib/components/Nav/Nav.tsx:28`, `scrollingNav.ts:12-32` | Unthrottled scroll listener; no `motion-reduce` | +| Nav | item color transitions, arrow rotation | `lib/components/Nav/Nav.Styles.ts:8-13`, `priority-plus.css:17,60` | No `motion-reduce` | +| Description | accordion icon rotation (`rotate-0` ↔ `rotate-90`) | `lib/components/Description/DescriptionAccordion.tsx:27`, `script.ts` | Content shows/hides via `hidden` attribute — no height animation | +| FilterPanel | dropdown arrow rotation + color | `lib/components/FilterPanel/FilterPanelTop.tsx:34`, `dropdown.ts:66,88,141` | No `motion-reduce` | +| ProgressBar | width fill animation | `lib/components/ProgressBar/ProgressBar.tsx:31-32` | `transition-all duration-1000` — animates `width` (H3 violation) | +| ImageSlider | carousel slide (inline `transform 0.5s`) | `lib/components/ImageSlider/script.ts:78-113` | JS-set transitions; 50 ms timeouts to sequence phases | +| ImageSlider item | 5 s background-image transition | `lib/components/ImageSlider/ImageSliderItem.tsx:31` | `duration-[5000]`. Borderline H6 | +| Loaders (21+) | `animate-pulse` skeletons | `lib/components/Loaders/**` | None opt into `motion-reduce` (Tailwind does NOT auto-disable) | +| PageLoader | `animate-spin` + slowed-spin under reduced-motion | `lib/components/Loaders/PageLoader/PageLoader.tsx:5` | **Only** component currently honouring reduced-motion | +| TopNavLoader | `animate-spin` | `lib/components/Loaders/TopNavLoader/TopNavLoader.tsx:6` | No `motion-reduce` | +| Splash / FullBanner / WideBanner video | `autoPlay muted loop playsInline` | `lib/hooks/useVideoBanner.tsx:39-41` | Loops indefinitely. No pause control. **H6 risk.** | +| Modal | none (snap `block`/`hidden`) | `lib/components/Modal/Modal.tsx:129` | Open/close is instant — felt jarring per typical UX | +| Dialog | none (uses native `` `showModal()`) | `lib/components/Dialog/Dialog.tsx:21` | Same — no entrance/exit | + +### 2.2 What's missing + +- **No motion design tokens.** Durations and easings are hard-coded (`duration-200`, `duration-300`, `duration-1000`, `duration-[5000]`, inline `0.5s`). +- **No `useReducedMotion` hook.** Only one component (PageLoader) uses a `motion-reduce:` Tailwind variant. +- **No IntersectionObserver-based scroll-reveal utility.** Priority Plus Nav uses `IntersectionObserver` internally but there's no shared hook. +- **No motion stories in Storybook.** 113 stories exist; zero exercise animation states or reduced-motion. +- **No Cypress tests around motion.** `cypress/e2e/Alert.cy.js` is the only e2e file; no motion-related assertions. +- **No documented motion principles** for contributors. + +### 2.3 Accessibility risks already in code + +| Severity | Risk | Where | Mitigation in this plan | +| ---------- | ---------------------------------------------------------------- | ---------------------------------------------------- | ---------------------------------------------------------------------------- | +| **High** | Auto-playing looped video with no pause control | `useVideoBanner.tsx`, Splash, FullBanner, WideBanner | Add pause button + respect `prefers-reduced-motion` (Phase 1 + 2) | +| **High** | All `animate-pulse` skeletons run during reduced-motion sessions | All Loaders | Wrap with `motion-safe:` (Phase 1) | +| **Medium** | Unthrottled scroll listener drives nav hide/show | `scrollingNav.ts:12` | Throttle via `requestAnimationFrame`; add `motion-reduce:` guard (Phase 2) | +| **Medium** | Card hover scale not disabled under reduced-motion | `Card.tsx:45` | Add `motion-safe:` (Phase 1) | +| **Medium** | Toast 200 ms JS timeout is coupled to CSS class | `Toast.tsx:48-50` | Use `transitionend` event or derive from token (Phase 2) | +| **Low** | ProgressBar animates `width` (not transform) | `ProgressBar.tsx:31-32` | Switch to `transform: scaleX()` with `transform-origin: left` (Phase 2) | +| **Low** | ImageSlider inline-style transitions are brittle | `ImageSlider/script.ts:78-113` | Move to data-attribute + CSS class transitions (Phase 3) | +| **Low** | Accordion shows/hides instantly via `hidden` attr | `DescriptionAccordion.tsx`, `script.ts` | Use `details`-style height animation OR a 200 ms opacity/translate (Phase 2) | + +--- + +## 3. Cross-Cutting Infrastructure (lands first) + +These foundational pieces are prerequisites for the per-component work. Build them in order; later components consume them. + +### 3.1 Motion design tokens + +Add a `motion` section to the Tailwind theme (`rds-tailwind-theme` if appropriate; otherwise extend locally in `tailwind.config.ts`). + +**Proposed token set:** + +```ts +// Durations (ms) +duration: { + 'cu-instant': '100ms', + 'cu-fast': '150ms', + 'cu-base': '250ms', + 'cu-slow': '400ms', + 'cu-slower': '600ms', + // Reserved for skeletons & progress only: + 'cu-fill': '1200ms', + 'cu-loop': '1800ms', +} + +// Easings (matched to perceptual roles) +transitionTimingFunction: { + 'cu-standard': 'cubic-bezier(0.2, 0, 0, 1)', // most UI motion + 'cu-emphasized': 'cubic-bezier(0.3, 0, 0, 1)', // entrance + 'cu-accelerate': 'cubic-bezier(0.3, 0, 1, 1)', // exit +} +``` + +**Effort:** SMALL (1–2 h). **Decision needed:** add to `rds-tailwind-theme` package or extend in `tailwind.config.ts` locally (see Open Question OQ1). + +### 3.2 `useReducedMotion` hook + +A tiny (~25 LOC) hook used by every component that animates via JavaScript. + +**Location:** `lib/hooks/useReducedMotion.ts` +**Contract:** + +```ts +// Returns true if user prefers reduced motion. +// Updates if the OS-level preference changes mid-session. +export const useReducedMotion = (): boolean +``` + +Implementation: `matchMedia('(prefers-reduced-motion: reduce)')` + `useState` + `addEventListener('change')`. SSR-safe (returns `false` if `window` unavailable). + +**Effort:** SMALL (1 h, including stories + a Cypress unit test). + +### 3.3 `useScrollReveal` hook (IntersectionObserver-based) + +Used by Card grids, Listing, Timeline, ImageGrid, content blocks for entrance animations on scroll-into-view. + +**Location:** `lib/hooks/useScrollReveal.ts` +**Contract:** + +```ts +useScrollReveal( + options?: { + threshold?: number; // default 0.15 + rootMargin?: string; // default '0px 0px -10% 0px' + once?: boolean; // default true — fire only on first reveal + disabled?: boolean; // bypass when consumer wants to opt out + } +): { ref: React.RefObject, isVisible: boolean } +``` + +Implementation rules: + +- **Auto-disables under `prefers-reduced-motion`** — `isVisible` is `true` from first render. +- Cleans up its observer on unmount. +- Reuses a single observer per component instance. + +**Effort:** SMALL–MEDIUM (2–3 h with stories and Cypress test). + +### 3.4 `useFocusTrap` hook (optional, only if Modal/Dialog improvements need it) + +Modal/Dialog currently rely on native `` semantics plus an `Escape` handler. If we add entrance/exit transitions we should NOT gate focus on the transition. If we add a non-`` overlay anywhere, we need this. Otherwise skip. + +**Effort:** SMALL (only if needed). + +### 3.5 Shared CSS keyframes + +A single `lib/styles/animations.css` (imported from `lib/style.css`) defining the keyframes used across components: + +```css +@keyframes cu-fade-in { + /* opacity 0 → 1 */ +} +@keyframes cu-fade-up { + /* translateY(8px) → 0, opacity 0 → 1 */ +} +@keyframes cu-scale-in { + /* scale(0.96) → 1, opacity 0 → 1 */ +} +@keyframes cu-slide-in-right { + /* translateX(8px) → 0, opacity 0 → 1 */ +} +@keyframes cu-shimmer { + /* for skeleton replacements, GPU-friendly */ +} + +@media (prefers-reduced-motion: reduce) { + .cu-anim-fade-in, + .cu-anim-fade-up, + .cu-anim-scale-in, + .cu-anim-slide-in-right { + animation: none !important; + opacity: 1 !important; + transform: none !important; + } + .cu-anim-shimmer { + animation-duration: 0s !important; + } +} +``` + +**Effort:** SMALL (1 h). + +### 3.6 Vanilla JS runtime — `cu-motion.js` (WordPress / non-React) + +A framework-free counterpart to `useScrollReveal`, shipped as a static asset in `dist/vanilla-js/cu-motion.js` for consumption by Gutenberg blocks, WordPress themes, and any non-React context that uses the RDS CSS. + +**Location:** `public/vanilla-js/cu-motion.js` (auto-copied to `dist/vanilla-js/cu-motion.js` by Vite's `copyPublicDir`). +**Contract:** + +- Auto-discovers elements with `data-cu-reveal` on `DOMContentLoaded`. +- Sets `data-revealed="true"` on each when it scrolls into view (`threshold: 0.15`, `rootMargin: '0px 0px -10% 0px'` — identical to the React hook). +- Honours `prefers-reduced-motion: reduce` by revealing everything immediately, no observer attached. +- Exposes `window.cuMotion.register(container)` for content inserted after page load (AJAX, load-more buttons). +- Idempotent — safe to load multiple times; second load is a no-op. +- ~60 lines, no dependencies, plain ES5-compatible JS (works in every browser RDS targets without transpilation). + +**Consumption (WordPress):** + +```php +// functions.php +wp_enqueue_style( + 'rds-styles', + '...rds/dist/style.css' +); +wp_enqueue_script( + 'cu-motion', + '...rds/dist/vanilla-js/cu-motion.js', + array(), + '1.0', + true +); +``` + +```php +// a dynamic block's render.php +
+

+

+
+``` + +The same CSS that animates React Cards animates these PHP-rendered Cards. The vanilla JS replaces only the trigger mechanism. + +**Caveat (no-JS fallback):** the current CSS sets `opacity: 0` on `.cu-card` unconditionally. If `cu-motion.js` fails to load on a WordPress page, cards will be invisible. Two ways to fix: + +1. Gate the initial-hidden state on `[data-cu-reveal]` — cards without the attribute render visibly. Requires React `Card` / `Listing` to emit `data-cu-reveal` when `revealOnScroll` is `true`. **Recommended.** +2. Add a `.cu-motion-ready` class to `` from `cu-motion.js` and gate the hidden state on that. Progressive enhancement — no JS = no animation, but cards are visible. + +Either fix is small (~15 minutes); not yet applied. See [OQ12](#9-open-questions-for-the-team). + +**Effort:** SMALL (already implemented as of 2026-05-11). + +### 3.7 Storybook conventions + +- Every animated component gains a **`Motion States` story** showing: idle / hover / focus / active / open / closed. +- Every animated component gains a **`Reduced Motion` story** that wraps the component in a `
` toggle (a CSS class that applies the reduced-motion overrides regardless of OS setting, for visual review). +- A new `lib/docs/Motion.mdx` documents the tokens, hooks, and rules listed above. + +**Effort:** MEDIUM (half day for convention + docs; per-component story additions counted in per-component effort). + +### 3.8 Testing approach + +| Concern | How we test | +| --------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `prefers-reduced-motion` honoured | Cypress: stub `window.matchMedia('(prefers-reduced-motion: reduce)')` to return `matches: true`, assert no `animate-*` classes are active and `transitionDuration` computes to `0s` | +| Focus order during open/close | Cypress: tab through after Modal opens; first focusable is body, last focus restored on close | +| No layout shift from hover scale | Manual CLS check via Lighthouse on Card grids | +| Axe a11y pass | `@storybook/addon-a11y` already in devDependencies — use the story-level a11y panel; add to CI | +| Visual regression | _Out of scope for v1_ (no current solution); consider Chromatic for Phase 5 | + +**Effort:** MEDIUM (cycle setup ~half day; per-test work counted per component). + +--- + +## 4. Component Inventory + +Counts: **53** component directories under `lib/components/`, **10** layouts under `lib/layouts/`, **21** loaders. Source: `lib/main.ts` exports. + +### Legend + +- **Story?** ✓ has `*.stories.tsx`, — otherwise. +- **CSS?** ✓ has component-local `styles.css`, — otherwise. +- **Motion now?** existing animation/transition code present. + +### 4.1 Navigation & Disclosure + +| Component | Files | Story? | CSS? | Motion now? | +| --------------------------------------------- | ------------------------------ | ------ | ---- | --------------------------------------- | +| Nav (Top, Bottom, Logo, Buttons, Aside, Menu) | `lib/components/Nav/` | ✓ | ✓ | yes — scroll hide, hover, priority-plus | +| Pagination | `lib/components/Pagination/` | ✓ | — | no | +| FilterPanel | `lib/components/FilterPanel/` | ✓ | — | yes — dropdown rotation | +| Modal | `lib/components/Modal/` | ✓ | — | no | +| Dialog | `lib/components/Dialog/` | ✓ | ✓ | no | +| Description (Accordion) | `lib/components/Description/` | ✓ | ✓ | yes — icon rotation | +| Details | `lib/components/Details/` | ✓ | ✓ | no (uses native `
`?) | +| LinkProvider | `lib/components/LinkProvider/` | — | — | no | + +### 4.2 Forms + +| Component | Files | Story? | CSS? | Motion now? | +| ------------------------ | ---------------------------------- | --------- | ---- | ------------------------- | +| Form (root + sub-fields) | `lib/components/Form/` | ✓ | ✓ | minor — focus transitions | +| Form.AutoSuggest | `lib/components/Form/AutoSuggest/` | (in Form) | ✓ | minor | +| Form.DateTime | `lib/components/Form/DateTime/` | (in Form) | ✓ | minor | +| InputAddon | `lib/components/Form/InputAddon/` | (in Form) | — | no | +| SearchInput | `lib/components/SearchInput/` | ✓ | — | no | +| LocationPicker | `lib/components/LocationPicker/` | ✓ | — | no | +| Login | `lib/components/Login/` | ✓ | ✓ | no | + +### 4.3 Cards & Listings + +| Component | Files | Story? | CSS? | Motion now? | +| --------------------- | ----------------------------- | ------ | ---- | ----------------- | +| Card (+ 15 sub-parts) | `lib/components/Card/` | ✓ | ✓ | yes — hover scale | +| Listing | `lib/components/Listing/` | ✓ | ✓ | no | +| Carleton360 | `lib/components/Carleton360/` | ✓ | ✓ | no | + +### 4.4 Content Blocks + +| Component | Files | Story? | CSS? | Motion now? | +| -------------- | -------------------------------- | ------ | ---- | ----------- | +| CallOut | `lib/components/CallOut/` | ✓ | ✓ | no | +| Quote | `lib/components/Quote/` | ✓ | — | no | +| Testimonial | `lib/components/Testimonial/` | ✓ | — | no | +| TextImage | `lib/components/TextImage/` | ✓ | ✓ | no | +| TextMedia | `lib/components/TextMedia/` | ✓ | — | no | +| Figure | `lib/components/Figure/` | ✓ | ✓ | no | +| FundingDetails | `lib/components/FundingDetails/` | ✓ | ✓ | no | +| Meta | `lib/components/Meta/` | — | — | no | +| Placeholder | `lib/components/Placeholder/` | ✓ | — | no | + +### 4.5 Banners, Heroes & Splash + +| Component | Files | Story? | CSS? | Motion now? | +| ---------- | ---------------------------- | ------ | ---- | ------------------------------------------ | +| Splash | `lib/components/Splash/` | ✓ | ✓ | yes — auto-play video bg | +| FullBanner | `lib/components/FullBanner/` | ✓ | ✓ | yes — auto-play video bg (when video used) | +| WideBanner | `lib/components/WideBanner/` | ✓ | ✓ | yes — auto-play video bg (when video used) | +| WideImage | `lib/components/WideImage/` | ✓ | ✓ | no | +| PageHeader | `lib/components/PageHeader/` | ✓ | ✓ | no | + +### 4.6 Media + +| Component | Files | Story? | CSS? | Motion now? | +| ------------------- | ------------------------------------- | ------ | ---- | -------------------- | +| Embed (+ HubSpot) | `lib/components/Embed/` | ✓ | ✓ | no | +| ImageGrid | `lib/components/ImageGrid/` | ✓ | ✓ | no | +| ImageSlider | `lib/components/ImageSlider/` | ✓ | — | yes — slide carousel | +| ImageCaptionOverlay | `lib/components/ImageCaptionOverlay/` | — | — | no | +| Icon | `lib/components/Icon/` | ✓ | — | no | +| Avatar | `lib/components/Avatar/` | ✓ | — | no | +| SocialIcons | `lib/components/SocialIcons/` | ✓ | ✓ | minor — hover | + +### 4.7 Calendar & Data Display + +| Component | Files | Story? | CSS? | Motion now? | +| ---------------- | ---------------------------------- | ------ | ---- | ----------- | +| Calendar | `lib/components/Calendar/` | ✓ | — | no | +| MultiDayCalendar | `lib/components/MultiDayCalendar/` | ✓ | — | no | +| Table | `lib/components/Table/` | ✓ | — | no | +| Timeline | `lib/components/Timeline/` | ✓ | ✓ | no | +| Location | `lib/components/Location/` | ✓ | — | no | + +### 4.8 Feedback & Status + +| Component | Files | Story? | CSS? | Motion now? | +| -------------------------- | ------------------------------- | -------------------------- | ---- | --------------------- | +| Alert | `lib/components/Alert/` | ✓ | ✓ | no | +| Toast | `lib/components/Toast/` | ✓ | — | yes — slide-in/out | +| Badge | `lib/components/Badge/` | ✓ | ✓ | no | +| BadgeGroup | `lib/components/BadgeGroup/` | ✓ | — | no | +| Button | `lib/components/Button/` | ✓ | ✓ | minor — hover/focus | +| ButtonGroup | `lib/components/ButtonGroup/` | ✓ | — | no | +| ProgressBar | `lib/components/ProgressBar/` | — (no story file detected) | — | yes — width animation | +| Forbidden403 / NotFound404 | `lib/components/ErrorMessages/` | ✓ | — | no | + +### 4.9 Skeleton Loaders (21) + +All use `animate-pulse`; none currently wrap with `motion-safe:`. + +| Loader | File | +| --------------------------------- | ------------------------------------------------------------------------------------ | +| BlockLoader | `lib/components/Loaders/BlockLoader/BlockLoader.tsx` | +| ButtonLoader | `lib/components/Loaders/ButtonLoader/ButtonLoader.tsx` | +| CalendarLoader | `lib/components/Loaders/CalendarLoader/CalendarLoader.tsx` | +| CardEventLoader | `lib/components/Loaders/CardLoader/CardEventLoader.tsx` | +| CardIconLoader | `lib/components/Loaders/CardLoader/CardIconLoader.tsx` | +| CardNewsLoader | `lib/components/Loaders/CardLoader/CardNewsLoader.tsx` | +| CardPageLoader | `lib/components/Loaders/CardLoader/CardPageLoader.tsx` | +| CardPeopleLoader | `lib/components/Loaders/CardLoader/CardPeopleLoader.tsx` | +| CardVideoLoader | `lib/components/Loaders/CardLoader/CardVideoLoader.tsx` | +| DescriptionLoader | `lib/components/Loaders/DescriptionLoader/DescriptionLoader.tsx` | +| DescriptionLoaderAccordion | `lib/components/Loaders/DescriptionLoader/DescriptionLoaderAccordion.tsx` | +| EventLoader | `lib/components/Loaders/EventLoader/EventLoader.tsx` | +| FormLoader (incl. RowLoader) | `lib/components/Loaders/FormLoader/` | +| ListingDescriptionLoader | `lib/components/Loaders/ListingLoader/ListingDescriptionLoader.tsx` | +| ListingEventLoader | `lib/components/Loaders/ListingLoader/ListingEventLoader.tsx` | +| ListingIconLoader | `lib/components/Loaders/ListingLoader/ListingIconLoader.tsx` | +| ListingNewsLoader | `lib/components/Loaders/ListingLoader/ListingNewsLoader.tsx` | +| ListingPageLoader | `lib/components/Loaders/ListingLoader/ListingPageLoader.tsx` | +| ListingPeopleLoader | `lib/components/Loaders/ListingLoader/ListingPeopleLoader.tsx` | +| PageHeaderLoader (+ Event/People) | `lib/components/Loaders/PageHeaderLoader/` | +| PageLoader | `lib/components/Loaders/PageLoader/PageLoader.tsx` — _only one with `motion-reduce`_ | +| PaginationLoader | `lib/components/Loaders/PaginationLoader/PaginationLoader.tsx` | +| TableLoader | `lib/components/Loaders/TableLoader/TableLoader.tsx` | +| TopNavLoader | `lib/components/Loaders/TopNavLoader/TopNavLoader.tsx` | + +### 4.10 Layouts + +| Component | Files | Story? | CSS? | Motion now? | +| ----------- | -------------------------- | ------ | ---- | ----------- | +| Article | `lib/layouts/Article/` | ✓ | — | no | +| Aside | `lib/layouts/Aside/` | ✓ | — | no | +| Body | `lib/layouts/Body/` | ✓ | — | no | +| Column | `lib/layouts/Column/` | ✓ | ✓ | no | +| FloatBox | `lib/layouts/FloatBox/` | ✓ | — | no | +| ImageCover | `lib/layouts/ImageCover/` | ✓ | ✓ | no | +| Main | `lib/layouts/Main/` | ✓ | — | no | +| Section | `lib/layouts/Section/` | ✓ | — | no | +| StackedList | `lib/layouts/StackedList/` | ✓ | ✓ | no | +| WideWave | `lib/layouts/WideWave/` | ✓ | ✓ | no | + +--- + +## 5. Per-Component Enhancement Plan + +> Priority: **HIGH** = high-traffic, high-impact; **MEDIUM** = visible but not on every page; **LOW** = edge / already polished. +> Effort: **S** = < 1 h; **M** = ½ day; **L** = multi-day, may need design input. + +### 5.1 Navigation & Disclosure + +#### Nav — HIGH / M + +- **Today:** Header has scroll-driven hide/show (top mobile, bottom always). Item hovers transition color. Priority-plus menu uses `IntersectionObserver` to collapse overflow items. +- **Enhancements:** + - Replace unthrottled scroll listener with `requestAnimationFrame`-throttled handler in `scrollingNav.ts`. + - Animate menu open/close (mobile menu) with `cu-fade-up` + 250 ms; today it's instant. + - Add subtle underline-grow on hover (CSS pseudo, transform: scaleX). Honour focus-visible only. + - Add `motion-reduce:` overrides for all `transition` classes (`Nav.Styles.ts:8-13`, `priority-plus.css:17,60`). +- **A11y:** Header hide must NEVER hide focused links. Test tabbing: if a link inside the header is focused while scrolled, the header must reveal. Mobile menu open/close must trap focus and return focus to trigger. + +#### Pagination — MEDIUM / S + +- **Today:** Static numeric pagination, no transitions. +- **Enhancements:** Active-page indicator slides between items (CSS `transform` on a single underline pill, not per-item background). 150 ms. +- **A11y:** Use `aria-current="page"`. Indicator is decorative — semantic state is in ARIA. + +#### FilterPanel — MEDIUM / M + +- **Today:** Arrow rotates 180° via class toggle; panel shows/hides instantly. +- **Enhancements:** + - Panel open: `cu-fade-up` (200 ms entrance). + - Panel close: snap (50 ms fade-out) — slower exits feel laggy. + - Animate the chevron with `transition-transform duration-cu-base ease-cu-standard` (token-driven). +- **A11y:** `aria-expanded` must update; focus must NOT jump on open (preserve user position). + +#### Modal — HIGH / M + +- **Today:** Renders or doesn't (`isOpen ? 'block' : 'hidden'`). Instant snap. +- **Enhancements:** + - Backdrop: fade in (`cu-fade-in`, 200 ms) and fade out (150 ms). + - Dialog: `cu-scale-in` (200 ms, scale 0.96 → 1 + opacity). + - Decouple unmount from animation: use a `mounted` state that lingers for the exit duration; OR use `transitionend`. +- **A11y:** + - Focus moves to first focusable inside Modal immediately on open — DO NOT wait for animation (H5). + - Focus restored to trigger on close, immediately. + - `Escape` key already handled; keep that behavior. + - Modal must NEVER autoplay any media. + +#### Dialog — HIGH / M + +- **Today:** Uses native `.showModal()` / `.close()`. No animation. +- **Enhancements:** Same as Modal. Native `` supports CSS animations via `&[open]` and `&::backdrop`. Animate scale/opacity; respect `motion-reduce`. +- **A11y:** Same rules as Modal. `preventClose` behavior (current implementation prevents Esc/backdrop close) is intentional — keep it. + +#### Description.Accordion — MEDIUM / M + +- **Today:** Toggles `hidden` attribute; icon rotates `rotate-0` ↔ `rotate-90`. No height transition. +- **Enhancements:** + - Replace `hidden` toggle with CSS-only `[data-open="true"]` controlling `grid-template-rows: 0fr → 1fr` trick (no JS height measurement, smooth open). 250 ms. + - Icon rotation: keep, add `motion-reduce:rotate-0` fallback (just snap). +- **A11y:** Maintain `aria-expanded` on the toggle. Content must remain keyboard-focusable when open. Avoid relying on `hidden` (it removes from tab order); use `aria-hidden` + CSS instead, or keep `hidden` but unset it _before_ animation starts. + +#### Details — LOW / S + +- **Today:** Appears to be presentational, not a `
` element. No motion. +- **Enhancements:** If used as a disclosure, mirror Description.Accordion. Otherwise no work. + +### 5.2 Forms + +#### Form fields (Input, Select, Textarea, Checkbox, Radio, etc.) — HIGH / M + +- **Today:** Tailwind `@tailwindcss/forms` plugin handles base styling; minor focus transitions. +- **Enhancements:** + - Standardise focus ring transition: 150 ms, `cu-standard`. + - Validation icon: fade-in on error (`cu-fade-in`, 150 ms). + - Label float on focused/filled (only if Carleton design language permits — see OQ4). +- **A11y:** + - Focus ring must be visible at all times when keyboard-focused — no opacity fade on focus itself. + - Error icon's appearance is decorative; the error message text is the semantic signal. + +#### Form.AutoSuggest — MEDIUM / M + +- **Today:** Built on `react-select`; has internal transitions. +- **Enhancements:** Suggestion dropdown should `cu-fade-up` on open. Limit to 200 ms. +- **A11y:** `aria-activedescendant` semantics already provided by react-select; ensure highlight is not motion-only. + +#### Form.DateTime — MEDIUM / M + +- **Today:** Wraps `react-date-picker` / `react-datepicker`. +- **Enhancements:** Picker open uses `cu-fade-up`. Month-change transitions should be SHORT (< 200 ms) and respect reduced-motion. +- **A11y:** Date picker has known a11y challenges; defer significant motion work until a11y baseline is verified. + +#### SearchInput — MEDIUM / S + +- **Today:** Static input. +- **Enhancements:** + - Clear button fade-in/out (`cu-fade-in`, 150 ms) when text present/absent. + - Submit-button hover: subtle background shift. +- **A11y:** Clear button must be a real `