Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions docs/uiux/reduced-motion-fallback-spec.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
# Reduced-Motion Fallback Specification

## Document Metadata

- Owner: UX Design and Research
- Partners: Engineering, Accessibility QA
- Status: Implemented — companion to `motion-reduced-motion-policy.md`
- Scope: Every animated component in the dashboard, its default motion, and its `prefers-reduced-motion` fallback
- WCAG baseline: WCAG 2.1 AA (Success Criterion 2.3.3 — Animation from Interactions)

## Purpose

`motion-reduced-motion-policy.md` defines the component-agnostic motion system. This document is the
**per-component specification**: it lists every animated component, classifies its reduced-motion
fallback as one of three predictable behaviors, and pins the implementation details so behavior is
reviewable and testable.

## Fallback classification

| Classification | Meaning |
| --- | --- |
| **Fade-only** | Any translate/scale path is removed; only opacity animates, capped at `--motion-duration-xs` (80 ms) |
| **No motion** | All transition/animation properties are removed (`transition: none` / `animation: none`); state changes are instant |
| **Dampened** | Duration shortened and amplitude reduced, but a (usually single-axis, opacity-only) transition remains |

## Per-component fallback table

| Component | File(s) | Default motion | Reduced-motion fallback | Classification |
| --- | --- | --- | --- | --- |
| Toast | `src/components/ToastItem.tsx`, `src/index.css` (`.toast-entering`, `.toast-exiting`) | Enter: slide + scale + fade (`toast-enter`, `--motion-duration-lg`). Exit: slide + fade (`toast-exit`, `--motion-duration-sm`). JS auto-dismiss 300 ms enter / 200 ms exit timers | `@keyframes` redefined to opacity-only; `.toast-entering`/`.toast-exiting` run at `--motion-duration-xs`. JS timers honour `useReducedMotion()` (80 ms both phases) | Fade-only |
| Modal | `src/index.css` (`.modal-backdrop`, `.modal-dialog`, `modal-in`) | Backdrop: fade in. Dialog: fade + scale (0.98 → 1) at `--motion-duration-md` | `modal-in` collapses to opacity-only at `--motion-duration-xs` | Fade-only |
| Drawer (audit log) | `src/components/audit-log/AuditLogDetailDrawer.tsx` (`auditDrawerIn`, `auditDrawerBackdropIn`) | Slide from right (`--motion-duration-lg`) + backdrop fade (`--motion-duration-md`) | In-component `<style>` block redefines keyframes to opacity-only at `--motion-duration-xs` | Fade-only |
| Drawer (API key detail) | `src/components/api-keys/ApiKeyDetailPanel.tsx` (`apiKeyDrawerIn`, `apiKeyDrawerBackdropIn`) | Slide from right (200 ms) + backdrop fade (150 ms) | In-component `<style>` block redefines keyframes to opacity-only at `--motion-duration-xs` | Fade-only |
| Skeleton shimmer | `src/index.css` (`.skeleton`) | `shimmer` background sweep, 2 s infinite | Swapped to `pulse` (static-position, opacity) 2 s infinite | Dampened |
| Skeleton crossfade | `src/components/SkeletonLoader.tsx` (`CrossfadeReveal`) | Staggered opacity crossfade (40 ms/step) | CSS transitions disabled — swap is instant; `loaded` still toggles visibility | No motion |
| Progress — wizard steps | `src/components/WizardProgress.tsx`, `src/index.css` (`.wizard-progress-item`, `.wizard-progress-marker`) | Background/border/color transitions (200 ms) + current-marker scale 1.06 | `transition: none` on both classes; marker scale removed | No motion |
| Progress — attestation timeline | `src/components/AttestationProgress.tsx`, `src/index.css` (`.ap-step`, `.ap-step-panel`, `.ap-chevron`) | Panel `max-height` expand (200 ms), background (140 ms), chevron rotate (140 ms) | `transition: none` on all three classes; expand/collapse is instant | No motion |
| Progress — usage meter fill | `src/components/UsageMeter.tsx` | Fill width transition 400 ms `cubic-bezier(0.2, 0, 0, 1)` | In-component `<style>` block: `transition: none !important` on the fill | No motion |
| Charts (entrance) | `src/index.css` (`.chart-entrance*`) | Bar-grow / line-draw scale entrances (`--motion-duration-lg`) | All collapsed to `chart-fade-in` at `--motion-duration-xs`, no transform | Fade-only |
| FAB loading spinner | `src/components/TriggerAttestationFAB.tsx` | `fab-spin` rotation, 1 s infinite | In-component `<style>` block: rotation disabled, `transition: none` | No motion |
| Address autocomplete spinner | `src/components/AddressAutocomplete.tsx` | `addr-spin` rotation, 0.7 s infinite | `animation: none` in `@media (prefers-reduced-motion: reduce)` | No motion |

## Rules for new animated components

1. All motion must use the `--motion-*` tokens from `motion-reduced-motion-policy.md`. Never hardcode durations or easing.
2. Every animation/transition must have a `@media (prefers-reduced-motion: reduce)` override in the same file (or a co-located `<style>` block when inline styles are unavoidable).
3. JS timing (timeouts driving animation state machines) must read the user preference through `useReducedMotion()` (`src/hooks/useReducedMotion.ts`) so DOM lifecycle never outlasts the CSS animation.
4. Do not use `transition` or `animation` in JSX `style` props — the ESLint rule `veritasor/no-inline-motion` warns on direct usage. Move motion to classes in `src/index.css` (or a co-located `<style>` block) where the reduced-motion override can live next to the rule.

## Validation checklist

- [ ] `npm run lint` passes (includes `veritasor/no-inline-motion` warnings)
- [ ] `npm run test` passes (CSS contract tests in `src/test/motion-tokens.test.ts`, ToastItem reduced-motion timing in `src/test/toast.test.tsx`, lint rule tests in `eslint-rules/no-inline-motion.test.js`)
- [ ] Manual pass with OS `prefers-reduced-motion: reduce` enabled: toasts fade (no slide), modals/drawers fade, skeletons pulse (no sweep), progress steps swap instantly, focus trap/order unaffected
- [ ] Keyboard and screen-reader pass: reduced-motion never hides content or delays focus

## Revision Notes

- `2026-08-02`: Initial per-component fallback specification (issue #302). Added modal entrance animation, wizard/attestation progress CSS overrides, reduced-motion-aware ToastItem timers, and `veritasor/no-inline-motion` lint rule.
47 changes: 47 additions & 0 deletions eslint-rules/no-inline-motion.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
/**
* Veritasor motion rule: `no-inline-motion`
*
* Flags direct `transition` / `animation` declarations inside JSX `style`
* props. Motion must live in CSS (src/index.css or a co-located `<style>`
* block) so every animation carries a co-located `prefers-reduced-motion`
* override — see `docs/uiux/reduced-motion-fallback-spec.md`.
*/
export default {
meta: {
type: 'suggestion',
docs: {
description: 'Disallow direct transition/animation usage in JSX style props',
category: 'Accessibility',
},
schema: [],
messages: {
inlineMotion:
'Do not use "{{prop}}" in a JSX style prop. Move motion to a CSS class with a prefers-reduced-motion override (docs/uiux/reduced-motion-fallback-spec.md).',
},
},
create(context) {
return {
JSXAttribute(node) {
if (node.name.type !== 'JSXIdentifier' || node.name.name !== 'style') return
const value = node.value
if (!value || value.type !== 'JSXExpressionContainer') return
const expr = value.expression
if (!expr || expr.type !== 'ObjectExpression') return

for (const prop of expr.properties) {
if (prop.type !== 'Property') continue
const key = prop.key
if (!key) continue
const keyName = key.type === 'Identifier' ? key.name : key.value
if (keyName === 'transition' || keyName === 'animation') {
context.report({
node: prop,
messageId: 'inlineMotion',
data: { prop: keyName },
})
}
}
},
}
},
}
48 changes: 48 additions & 0 deletions eslint-rules/no-inline-motion.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
/**
* Tests for the `veritasor/no-inline-motion` ESLint rule.
*
* The rule guards the reduced-motion fallback spec
* (docs/uiux/reduced-motion-fallback-spec.md): inline `transition` /
* `animation` in JSX style props cannot carry a prefers-reduced-motion
* override, so they are flagged.
*/
import { RuleTester } from 'eslint'
import rule from './no-inline-motion.js'

const ruleTester = new RuleTester({
languageOptions: {
ecmaVersion: 2022,
sourceType: 'module',
parserOptions: {
ecmaFeatures: { jsx: true },
},
},
})

ruleTester.run('no-inline-motion', rule, {
valid: [
'<div />',
'<div className="animated" />',
'<div style={{ color: "red", margin: 4 }} />',
'<div style={{ transform: "scale(1.06)" }} />',
'<Component style={styles.panel} />',
'<div style={{"--motion-duration-md": "200ms"}} />',
],
invalid: [
{
code: '<div style={{ transition: "opacity 200ms ease" }} />',
errors: [{ messageId: 'inlineMotion' }],
},
{
code: '<div style={{ animation: "fade-in 1s forwards" }} />',
errors: [{ messageId: 'inlineMotion' }],
},
{
code: '<div style={{ transition: "all 0.2s", animation: "spin 1s infinite" }} />',
errors: [
{ messageId: 'inlineMotion' },
{ messageId: 'inlineMotion' },
],
},
],
})
7 changes: 7 additions & 0 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import noInlineMotion from './eslint-rules/no-inline-motion.js'

export default tseslint.config(
{
Expand All @@ -18,11 +19,17 @@ export default tseslint.config(
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
veritasor: {
rules: {
'no-inline-motion': noInlineMotion,
},
},
},
rules: {
...reactHooks.configs.recommended.rules,
'react-refresh/only-export-components': ['warn', { allowConstantExport: true }],
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }],
'veritasor/no-inline-motion': 'warn',
},
},
)
9 changes: 5 additions & 4 deletions src/components/AttestationProgress.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -277,16 +277,17 @@ function ExpandableStep({ step, stepStatus, index, defaultExpanded = false }: Ex
const statusText =
stepStatus === 'completed' ? 'Done' : isCurrent ? 'In progress' : 'Pending'

// Smooth height animation
// Smooth height animation (transition lives in CSS class .ap-step-panel so
// prefers-reduced-motion can override it — see reduced-motion-fallback-spec.md)
const panelStyle: React.CSSProperties = {
overflow: 'hidden',
transition: 'max-height var(--motion-duration-md) var(--motion-easing-standard)',
maxHeight: expanded ? '20rem' : '0',
}

return (
<li
aria-current={isCurrent ? 'step' : undefined}
className="ap-step"
style={{
borderRadius: 14,
border: '1px solid var(--border)',
Expand All @@ -297,7 +298,6 @@ function ExpandableStep({ step, stepStatus, index, defaultExpanded = false }: Ex
? 'rgba(94,234,212,0.06)'
: 'transparent',
overflow: 'hidden',
transition: 'background var(--motion-duration-sm) ease',
}}
>
{/* Header row — acts as the toggle trigger */}
Expand Down Expand Up @@ -377,11 +377,11 @@ function ExpandableStep({ step, stepStatus, index, defaultExpanded = false }: Ex
{/* Chevron */}
<span
aria-hidden="true"
className="ap-chevron"
style={{
fontSize: '0.6rem',
color: 'var(--muted)',
transform: expanded ? 'rotate(180deg)' : 'rotate(0deg)',
transition: 'transform var(--motion-duration-sm) ease',
display: 'inline-block',
}}
>
Expand All @@ -396,6 +396,7 @@ function ExpandableStep({ step, stepStatus, index, defaultExpanded = false }: Ex
role="region"
aria-labelledby={triggerId}
ref={panelRef}
className="ap-step-panel"
style={panelStyle}
>
<div
Expand Down
21 changes: 17 additions & 4 deletions src/components/ToastItem.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,18 @@
import React, { useEffect, useState, useRef, useCallback } from 'react'
import { Toast } from './ToastContext'
import { resolveAutoDismissMs } from './toastRules'
import { useReducedMotion } from '../hooks/useReducedMotion'

export type ToastAnimationState = 'entering' | 'idle' | 'exiting'

// Match the CSS motion tokens so the JS lifecycle stays in lock-step with the
// animation: --motion-duration-lg (280ms) enter, --motion-duration-sm (140ms)
// exit. Under prefers-reduced-motion the CSS collapses both to
// --motion-duration-xs (80ms), so the JS timers shorten to match.
const ENTER_DELAY_MS = 280
const EXIT_DELAY_MS = 140
const REDUCED_MOTION_DELAY_MS = 80

interface ToastItemProps {
toast: Toast
onRemove: (id: string) => void
Expand All @@ -17,6 +26,10 @@ interface ToastItemProps {
export default function ToastItem({ toast, onRemove, disableMotion = false }: ToastItemProps) {
const { id, type, message, duration, onUndo, undoLabel = 'Undo', count } = toast

const reducedMotion = useReducedMotion()
const enterDelayMs = reducedMotion ? REDUCED_MOTION_DELAY_MS : ENTER_DELAY_MS
const exitDelayMs = reducedMotion ? REDUCED_MOTION_DELAY_MS : EXIT_DELAY_MS

// Auto-dismiss duration: success/info default to 5000ms, warning/error persist
// (0) unless explicitly overridden. ToastItem.tsx consults `toastRules` so the
// documented cadence stays in lock-step with the spec.
Expand All @@ -40,11 +53,11 @@ export default function ToastItem({ toast, onRemove, disableMotion = false }: To
const frame = requestAnimationFrame(() => {
const timer = setTimeout(() => {
setAnimationState('idle')
}, 300) // matches motion.duration.lg
}, enterDelayMs) // matches motion.duration.lg (or xs under reduced motion)
return () => clearTimeout(timer)
})
return () => cancelAnimationFrame(frame)
}, [disableMotion])
}, [disableMotion, enterDelayMs])

// Cleanup exit timer on unmount
useEffect(() => {
Expand Down Expand Up @@ -74,8 +87,8 @@ export default function ToastItem({ toast, onRemove, disableMotion = false }: To
// Wait for exit animation to complete before removing from DOM
exitTimerRef.current = setTimeout(() => {
onRemove(id)
}, 200) // matches motion.duration.sm for fast exit
}, [id, onRemove, disableMotion])
}, exitDelayMs) // matches motion.duration.sm (or xs under reduced motion)
}, [id, onRemove, disableMotion, exitDelayMs])

// Countdown timer logic
useEffect(() => {
Expand Down
2 changes: 0 additions & 2 deletions src/components/WizardProgress.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,12 +48,10 @@ export default function WizardProgress({
isCurrent ? 'is-current' : ''
}`}
aria-current={isCurrent ? 'step' : undefined}
style={{ transition: 'background-color 200ms ease, border-color 200ms ease, color 200ms ease' }}
>
<span
className="wizard-progress-marker"
aria-hidden="true"
style={{ transition: 'transform 200ms ease', transform: isCurrent ? 'scale(1.06)' : 'scale(1)' }}
>
{isComplete ? '✓' : index + 1}
</span>
Expand Down
69 changes: 69 additions & 0 deletions src/hooks/useReducedMotion.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { renderHook, act } from '@testing-library/react'
import { describe, expect, it, vi, afterEach } from 'vitest'
import { useReducedMotion } from '../hooks/useReducedMotion'

function mockMatchMedia(matches: boolean) {
vi.spyOn(window, 'matchMedia').mockImplementation((query) => ({
matches: query === '(prefers-reduced-motion: reduce)' ? matches : false,
media: query,
onchange: null,
addListener: () => {},
removeListener: () => {},
addEventListener: () => {},
removeEventListener: () => {},
dispatchEvent: () => false,
}) as MediaQueryList)
}

describe('useReducedMotion', () => {
afterEach(() => {
vi.restoreAllMocks()
})

it('returns false when prefers-reduced-motion does not match', () => {
mockMatchMedia(false)
const { result } = renderHook(() => useReducedMotion())
expect(result.current).toBe(false)
})

it('returns true when prefers-reduced-motion matches', () => {
mockMatchMedia(true)
const { result } = renderHook(() => useReducedMotion())
expect(result.current).toBe(true)
})

it('updates when the preference changes at runtime', () => {
let matches = false
let changeListener: ((e: { matches: boolean }) => void) | null = null
vi.spyOn(window, 'matchMedia').mockImplementation((query) => ({
matches: query === '(prefers-reduced-motion: reduce)' ? matches : false,
media: query,
onchange: null,
addListener: () => {},
removeListener: () => {},
addEventListener: (_type: string, cb: (e: { matches: boolean }) => void) => {
changeListener = cb
},
removeEventListener: () => {},
dispatchEvent: () => false,
}) as MediaQueryList)

const { result } = renderHook(() => useReducedMotion())
expect(result.current).toBe(false)

matches = true
act(() => {
changeListener?.({ matches: true })
})
expect(result.current).toBe(true)
})

it('falls back to false when matchMedia is unavailable', () => {
const original = window.matchMedia
// @ts-expect-error — simulating an environment without matchMedia
delete window.matchMedia
const { result } = renderHook(() => useReducedMotion())
expect(result.current).toBe(false)
window.matchMedia = original
})
})
Loading