diff --git a/.audit/animation-polish.tsv b/.audit/animation-polish.tsv new file mode 100644 index 000000000..a858ccd63 --- /dev/null +++ b/.audit/animation-polish.tsv @@ -0,0 +1,17 @@ +ts phase decision why evidence result +2026-08-31T00:00:00Z isolation Used the existing clean T3 worktree and t3code/animation-polish branch It is already isolated from main and all other attached worktrees git worktree list --porcelain clean at acaac0d +2026-08-31T00:00:01Z design Kept the existing DebateLab visual system and set restrained motion intensity The plan asks for polish and accessibility, not a redesign plans/animation-polish.md motion target set to purposeful state changes +2026-08-31T00:00:02Z runtime Installed Node 22 through NVM before verification package.json and CI require Node 22, while the VPS only had Node 24 available package.json Node 22 installation started +2026-08-31T15:28:36Z runtime Completed Node 22 installation and clean npm dependency install Matched the repository engine and CI runtime before checking code package.json Node v22.23.2 and npm 10.9.8 active +2026-08-31T15:31:21Z browser Rejected the first E2E result because Playwright reused another worktree server on port 8080 The browser did not contain this branch's route evidence attributes test-results error contexts rerun isolated on port 8187 +2026-08-31T15:33:01Z browser Passed the focused normal and reduced-motion matrix on desktop and mobile Used a dedicated port and deterministic local mock provider e2e/animation-polish.spec.ts 4 tests passed +2026-08-31T15:40:34Z implementation Closed remaining route state and reduced-motion audit findings Luna audits found lazy fallback mobile menu timers smooth scrolling focus and nested result motion gaps src/App.tsx, src/index.css, src/components/landing/Hero.tsx 57 focused tests plus lint and typecheck passed +2026-08-31T15:50:00Z verification Passed the full Node 22 unit coverage and production build gate Fresh checks covered the complete maintained test scope and compiled release output coverage summary and dist build 874 tests passed before final review fixes +2026-08-31T15:57:00Z verification Passed the complete desktop and mobile browser suite after isolating the server and bounding workers The four-worker run exposed fixture timeouts under contention, while the CI-parity two-worker run was stable e2e suite on port 8191 116 tests passed +2026-08-31T16:03:30Z review Fixed the mounted-result scroll race and expanded the feature-state browser matrix Independent Luna reviews found one code race and incomplete options/state coverage src/pages/GeneratePage.tsx, e2e/animation-polish.spec.ts focused unit and 6 browser tests passed +2026-08-31T16:11:13Z verification Passed final coverage build and full browser gates on the reviewed diff Re-ran every required check after review changes and inspected all eight normal and reduced-motion screenshots coverage summary, build output, test-results/animation-polish-* 875 tests and 118 E2E tests passed +2026-08-31T16:11:13Z visual Manually inspected desktop and mobile empty/options and result screenshots in normal and reduced-motion modes Confirmed readable stable layouts with no toast overlay or missing result sections test-results/animation-polish-* 8 screenshots accepted +2026-08-31T16:15:00Z integration Detected origin/main advancing from acaac0d to 66640be before commit Final review compared against the live base and found the parallel analytics merge git log HEAD..origin/main rebase required before PR +2026-08-31T16:17:00Z integration Rebased the feature commit onto origin/main and preserved analytics plus motion behavior The only conflict was adjacent App imports from the analytics and animation commits src/App.tsx rebase completed at 7075d4e +2026-08-31T16:22:00Z verification Passed the complete gate after rebasing onto analytics main Fresh checks included the new analytics tests and regenerated browser evidence on an isolated port coverage summary, build output, Playwright port 8194 929 tests and 118 E2E tests passed +2026-08-31T16:32:00Z review Accepted all five current-head CodeRabbit findings Each finding reproduced a bounded reduced-motion state or React lifecycle issue PR 20 review threads fixes implemented for fresh verification diff --git a/e2e/animation-polish.spec.ts b/e2e/animation-polish.spec.ts new file mode 100644 index 000000000..82a33830f --- /dev/null +++ b/e2e/animation-polish.spec.ts @@ -0,0 +1,128 @@ +import { expect, test, type Page } from '@playwright/test'; +import { stubSupabaseSession } from './helpers/auth'; + +const featureConfig = { provider: 'mock', model: 'local', temperature: 0.7 }; +const STATE_TIMEOUT_MS = 15_000; + +async function prepareMotionFixture(page: Page, reducedMotion: 'no-preference' | 'reduce') { + await page.emulateMedia({ reducedMotion }); + await stubSupabaseSession(page); + await page.addInitScript((config) => { + window.localStorage.setItem('debate-trainer-settings', JSON.stringify({ + state: { + language: 'en', + outputLanguage: 'en', + theme: 'light', + featureConfigs: { + generate: config, + attack: config, + brainstorm: config, + factoids: config, + ask: config, + sparring: config, + }, + wikiResearchEnabled: false, + }, + version: 0, + })); + }, featureConfig); +} + +async function openGenerateOptions(page: Page) { + await page.goto('/app/generate', { waitUntil: 'domcontentloaded' }); + await expect(page.getByText('Ready to Generate')).toBeVisible({ timeout: STATE_TIMEOUT_MS }); + const options = page.locator('#generate-more-options'); + expect(await options.evaluate((element) => (element as HTMLElement).inert)).toBe(true); + await page.getByRole('button', { name: 'More options' }).click(); + await expect(options).toHaveAttribute('data-open', 'true'); + expect(await options.evaluate((element) => (element as HTMLElement).inert)).toBe(false); + await page.getByRole('button', { name: 'More options' }).click(); + await expect(options).toHaveAttribute('data-open', 'false'); + expect(await options.evaluate((element) => (element as HTMLElement).inert)).toBe(true); + await page.getByRole('button', { name: 'More options' }).click(); + await expect(options).toHaveAttribute('data-open', 'true'); + return options; +} + +async function generateMockCase(page: Page) { + await page.getByLabel('Motion / Topic').fill('This House would make public transport free.'); + await page.getByRole('button', { name: 'Generate Debate Case' }).click(); + await expect(page.getByText('Reading the motion...')).toBeVisible({ timeout: STATE_TIMEOUT_MS }); + await expect(page.getByText(/We support the motion/)).toBeVisible({ timeout: STATE_TIMEOUT_MS }); +} + +test('keeps route and feature-state motion short and stable', async ({ page }, testInfo) => { + await prepareMotionFixture(page, 'no-preference'); + const options = await openGenerateOptions(page); + + const route = page.locator('[data-route-transition-ms="160"]'); + await expect(route).toHaveAttribute('data-reduced-motion', 'false'); + const transitionMs = await options.evaluate((element) => Number.parseFloat(getComputedStyle(element).transitionDuration) * 1000); + expect(transitionMs).toBeGreaterThan(0); + await page.screenshot({ path: testInfo.outputPath('normal-empty-options.png'), fullPage: true }); + + await generateMockCase(page); + await expect(page.locator('#generate-results')).toBeVisible(); + await expect.poll(() => page.evaluate(() => window.scrollY)).toBeGreaterThan(0); + await expect(page.locator('main.page-enter')).toHaveCount(0); + await expect(page.getByText('Generate complete', { exact: true })).toBeHidden({ timeout: 10_000 }); + await page.screenshot({ path: testInfo.outputPath('normal-result.png'), fullPage: true }); +}); + +test('removes non-essential route and state movement for reduced motion', async ({ page }, testInfo) => { + await prepareMotionFixture(page, 'reduce'); + const options = await openGenerateOptions(page); + + const route = page.locator('[data-route-transition-ms="160"]'); + await expect(route).toHaveAttribute('data-reduced-motion', 'true'); + await expect.poll(() => options.evaluate((element) => getComputedStyle(element).transitionDuration)).toBe('0s'); + await expect.poll(() => page.evaluate(() => document.getAnimations().filter((animation) => animation.playState === 'running').length)).toBe(0); + await page.screenshot({ path: testInfo.outputPath('reduced-empty-options.png'), fullPage: true }); + + await generateMockCase(page); + await expect(page.locator('#generate-results')).toBeVisible(); + await expect.poll(() => page.evaluate(() => window.scrollY)).toBeGreaterThan(0); + await expect.poll(() => page.evaluate(() => document.getAnimations().filter((animation) => animation.playState === 'running').length)).toBe(0); + await expect(page.getByText('Generate complete', { exact: true })).toBeHidden({ timeout: 10_000 }); + await page.screenshot({ path: testInfo.outputPath('reduced-result.png'), fullPage: true }); +}); + +test('audits applicable empty, loading, result, and option states across feature routes', async ({ page }, testInfo) => { + test.setTimeout(90_000); + await prepareMotionFixture(page, 'no-preference'); + + await page.goto('/app/attack', { waitUntil: 'domcontentloaded' }); + await expect(page.getByText('No Mercy Mode')).toBeVisible({ timeout: STATE_TIMEOUT_MS }); + const attackOptions = page.locator('#attack-options'); + await page.getByRole('button', { name: 'More options' }).click(); + await expect(attackOptions).toHaveAttribute('data-open', 'true'); + await page.getByRole('button', { name: 'More options' }).click(); + await expect(attackOptions).toHaveAttribute('data-open', 'false'); + expect(await attackOptions.evaluate((element) => (element as HTMLElement).inert)).toBe(true); + await page.getByLabel('Your Argument').fill('Universal transit improves access, reduces congestion, and strengthens civic participation.'); + await page.getByRole('button', { name: 'Find Weaknesses' }).click(); + await expect(page.getByText('Analyzing the case structure...')).toBeVisible({ timeout: STATE_TIMEOUT_MS }); + await expect(page.getByRole('heading', { name: 'Overall Assessment' })).toBeVisible({ timeout: STATE_TIMEOUT_MS }); + await page.screenshot({ path: testInfo.outputPath('attack-result.png'), fullPage: true }); + + await page.goto('/app/brainstorm', { waitUntil: 'domcontentloaded' }); + await expect(page.getByText('Unlock Creative Angles')).toBeVisible({ timeout: STATE_TIMEOUT_MS }); + await page.getByLabel('Motion / Topic').fill('This House would make public transport free.'); + await page.getByRole('button', { name: 'Start Brainstorming' }).click(); + await expect(page.getByText('Exploring angles...')).toBeVisible({ timeout: STATE_TIMEOUT_MS }); + await expect(page.getByRole('heading', { name: 'Top 5 Argument Angles' })).toBeVisible({ timeout: STATE_TIMEOUT_MS }); + + await page.goto('/app/factoids', { waitUntil: 'domcontentloaded' }); + await expect(page.getByText('Evidence That Wins')).toBeVisible({ timeout: STATE_TIMEOUT_MS }); + await page.getByLabel('Statement').fill('Free public transport reduces urban congestion.'); + await page.getByRole('button', { name: 'Find Factoids' }).click(); + await expect(page.getByText('Searching databases...')).toBeVisible({ timeout: STATE_TIMEOUT_MS }); + await expect(page.getByRole('heading', { name: 'Factoids' })).toBeVisible({ timeout: STATE_TIMEOUT_MS }); + + await page.goto('/app/ask', { waitUntil: 'domcontentloaded' }); + await expect(page.getByText('Start a conversation')).toBeVisible({ timeout: STATE_TIMEOUT_MS }); + await page.getByPlaceholder('Ask your debate coach...').fill('How should I weigh access against cost?'); + await page.getByRole('button', { name: 'Send message' }).click(); + await expect(page.getByText('Thinking...')).toBeVisible({ timeout: STATE_TIMEOUT_MS }); + await expect(page.getByText(/Great question about/)).toBeVisible({ timeout: STATE_TIMEOUT_MS }); +}); diff --git a/src/App.tsx b/src/App.tsx index 05efebe28..d52306eba 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -5,7 +5,7 @@ import { Toaster as Sonner } from "@/components/ui/sonner"; import { TooltipProvider } from "@/components/ui/tooltip"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { BrowserRouter, Routes, Route, useLocation } from "react-router-dom"; -import { AnimatePresence, motion, useReducedMotion } from "framer-motion"; +import { AnimatePresence, MotionConfig, motion, useReducedMotion } from "framer-motion"; import { AppLayout } from "@/components/layout/AppLayout"; import { AuthProvider } from "@/lib/supabase/AuthProvider"; import { RequireAuth } from "@/components/auth/RequireAuth"; @@ -15,6 +15,7 @@ import { useSessionStorageSync } from "@/hooks/use-session-storage-sync"; import { Loader2 } from "lucide-react"; import { OfflineIndicator } from "@/components/OfflineIndicator"; import { captureException, captureProductEvent } from "@/lib/observability"; +import { getRouteMotion, ROUTE_TRANSITION_DURATION_SECONDS } from "@/lib/motion-policy"; // Lazy load all pages for code splitting const LandingPage = lazy(() => import("./pages/LandingPage")); @@ -78,7 +79,7 @@ function getToolForPathname(pathname: string): ToolName | undefined { // Loading fallback component const PageLoader = () => (
-
+
@@ -131,19 +132,20 @@ export function ToolRouteAnalytics(): null { function AnimatedRoutes() { const location = useLocation(); const shouldReduceMotion = useReducedMotion(); + const routeMotion = getRouteMotion(Boolean(shouldReduceMotion)); useSessionStorageSync(); return ( - + }> + {/* Public Routes */} } /> } /> @@ -178,7 +180,8 @@ function AnimatedRoutes() { } /> } /> - + + ); @@ -188,18 +191,18 @@ const App = () => ( - - - - - - - }> + + + + + + + - - - - + + + + diff --git a/src/components/features/ask/AskChat.tsx b/src/components/features/ask/AskChat.tsx index 2f3871225..193ae1c9a 100644 --- a/src/components/features/ask/AskChat.tsx +++ b/src/components/features/ask/AskChat.tsx @@ -16,6 +16,7 @@ import { validateFileUpload, MAX_FILE_COUNT, formatFileSize } from '@/lib/valida import { formatEuropeanDate, formatEuropeanTime } from '@/lib/date-format'; import { ModelSelector } from '@/components/ModelSelector'; import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from '@/components/ui/sheet'; +import { useReducedMotion } from 'framer-motion'; interface Message { id: string; @@ -368,14 +369,15 @@ export function AskChat({ messages, isLoading, onSend, onClear, onSaveChat, save const fileInputRef = useRef(null); const messagesEndRef = useRef(null); const { toast } = useToast(); + const shouldReduceMotion = useReducedMotion(); const { isListening, transcript, error: voiceError, startListening, stopListening, resetTranscript, hasSupport } = useSpeechToText(); const hasStreamingMessage = messages.some((message) => message.isStreaming); // Auto-scroll to bottom on new messages or streaming content useEffect(() => { - messagesEndRef.current?.scrollIntoView?.({ behavior: 'smooth' }); - }, [messages]); + messagesEndRef.current?.scrollIntoView?.({ behavior: shouldReduceMotion ? 'auto' : 'smooth' }); + }, [messages, shouldReduceMotion]); // Cycle through loading messages useEffect(() => { diff --git a/src/components/features/attack/AttackForm.tsx b/src/components/features/attack/AttackForm.tsx index 8d170e80c..04ff0a69a 100644 --- a/src/components/features/attack/AttackForm.tsx +++ b/src/components/features/attack/AttackForm.tsx @@ -1,5 +1,5 @@ // src/components/features/attack/AttackForm.tsx -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; @@ -32,6 +32,11 @@ export function AttackForm({ onSubmit, isLoading, importedCase }: AttackFormProp const { t } = useTranslation(); const { attackForm, setAttackForm } = useSessionStore(); const [optionsOpen, setOptionsOpen] = useState(false); + const advancedOptionsRef = useRef(null); + + useEffect(() => { + if (advancedOptionsRef.current) advancedOptionsRef.current.inert = !optionsOpen; + }, [optionsOpen]); const { register, handleSubmit, setValue, watch, formState: { errors } } = useForm({ resolver: zodResolver(AttackInputSchema), defaultValues: { @@ -114,9 +119,11 @@ export function AttackForm({ onSubmit, isLoading, importedCase }: AttackFormProp
{/* Settings Grid */} diff --git a/src/components/features/generate/GenerateForm.tsx b/src/components/features/generate/GenerateForm.tsx index 699c4f310..be5ab3ceb 100644 --- a/src/components/features/generate/GenerateForm.tsx +++ b/src/components/features/generate/GenerateForm.tsx @@ -1,5 +1,5 @@ // src/components/features/generate/GenerateForm.tsx -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; @@ -31,6 +31,11 @@ export function GenerateForm({ onSubmit, isLoading }: GenerateFormProps) { const { t } = useTranslation(); const { generateForm, setGenerateForm } = useSessionStore(); const [advancedOpen, setAdvancedOpen] = useState(false); + const advancedOptionsRef = useRef(null); + + useEffect(() => { + if (advancedOptionsRef.current) advancedOptionsRef.current.inert = !advancedOpen; + }, [advancedOpen]); const { register, handleSubmit, setValue, watch, formState: { errors } } = useForm({ resolver: zodResolver(GenerateInputSchema), @@ -198,9 +203,11 @@ export function GenerateForm({ onSubmit, isLoading }: GenerateFormProps) {
{/* Length */} diff --git a/src/components/landing/Features.tsx b/src/components/landing/Features.tsx index ba8464a82..4b758882f 100644 --- a/src/components/landing/Features.tsx +++ b/src/components/landing/Features.tsx @@ -61,7 +61,7 @@ const Features = () => { className="feature-card group tilt-card" >
- +

{feature.title}

{feature.description}

diff --git a/src/components/landing/Hero.tsx b/src/components/landing/Hero.tsx index cba207e07..78e9dd9a6 100644 --- a/src/components/landing/Hero.tsx +++ b/src/components/landing/Hero.tsx @@ -1,6 +1,6 @@ import React from "react"; import { useTranslation } from "react-i18next"; -import { motion } from "framer-motion"; +import { motion, useReducedMotion } from "framer-motion"; import { ArrowRight, CheckCircle2, Sparkles } from "lucide-react"; const Hero = () => { @@ -128,11 +128,15 @@ const PHASE_TIMINGS: Record = { impact: 1800, }; -const TypewriterText = ({ text, durationMs }: { text: string; durationMs: number }) => { +const TypewriterText = ({ text, durationMs, shouldReduceMotion }: { text: string; durationMs: number; shouldReduceMotion: boolean }) => { const [displayed, setDisplayed] = React.useState(""); const intervalRef = React.useRef | null>(null); React.useEffect(() => { + if (shouldReduceMotion) { + setDisplayed(text); + return; + } let i = 0; setDisplayed(""); const charInterval = durationMs / text.length; @@ -142,21 +146,26 @@ const TypewriterText = ({ text, durationMs }: { text: string; durationMs: number if (i >= text.length && intervalRef.current) clearInterval(intervalRef.current); }, charInterval); return () => { if (intervalRef.current) clearInterval(intervalRef.current); }; - }, [text, durationMs]); + }, [text, durationMs, shouldReduceMotion]); return <>{displayed}|; }; const HeroPreview = () => { const { t } = useTranslation(); + const shouldReduceMotion = Boolean(useReducedMotion()); const outputContent = { thesis: t('hero.preview.thesis'), contention: t('hero.preview.contention'), impact: t('hero.preview.outputImpact'), }; - const [phase, setPhase] = React.useState("idle"); + const [phase, setPhase] = React.useState(shouldReduceMotion ? "impact" : "idle"); React.useEffect(() => { + if (shouldReduceMotion) { + setPhase("impact"); + return; + } const phases: Phase[] = ["idle", "shimmer", "generating", "thesis", "contention", "impact"]; let idx = 0; let timeout: ReturnType; @@ -169,7 +178,7 @@ const HeroPreview = () => { timeout = setTimeout(advance, PHASE_TIMINGS[phases[0]]); return () => clearTimeout(timeout); - }, []); + }, [shouldReduceMotion]); const showThesis = phase === "thesis" || phase === "contention" || phase === "impact"; const showContention = phase === "contention" || phase === "impact"; @@ -242,42 +251,42 @@ const HeroPreview = () => { {showThesis && (

{t('hero.preview.mainArgument')}

- {phase === "thesis" ? : outputContent.thesis} + {phase === "thesis" ? : outputContent.thesis}

)} {showContention && (

{t('hero.preview.reasoning')}

- {phase === "contention" ? : outputContent.contention} + {phase === "contention" ? : outputContent.contention}

)} {showImpact && (

{t('hero.preview.impact')}

- +

)} diff --git a/src/components/landing/HowItWorks.tsx b/src/components/landing/HowItWorks.tsx index d4f5e655f..c7e4c99a6 100644 --- a/src/components/landing/HowItWorks.tsx +++ b/src/components/landing/HowItWorks.tsx @@ -43,7 +43,7 @@ const HowItWorks = () => { className="relative flex flex-col items-center text-center" >
-
+
diff --git a/src/components/landing/Navbar.tsx b/src/components/landing/Navbar.tsx index 34da7d7d6..3bd9b55bf 100644 --- a/src/components/landing/Navbar.tsx +++ b/src/components/landing/Navbar.tsx @@ -1,12 +1,13 @@ import { useTranslation } from "react-i18next"; import { useState } from "react"; -import { motion, AnimatePresence } from "framer-motion"; +import { motion, AnimatePresence, useReducedMotion } from "framer-motion"; import { Menu, X, ArrowRight } from "lucide-react"; import { LanguageSwitcher } from "@/components/LanguageSwitcher"; const Navbar = () => { const { t } = useTranslation(); const [isOpen, setIsOpen] = useState(false); + const shouldReduceMotion = useReducedMotion(); const navLinks = [ { href: "#features", label: t('nav.features') }, @@ -70,7 +71,7 @@ const Navbar = () => { initial={{ opacity: 0, height: 0 }} animate={{ opacity: 1, height: "auto" }} exit={{ opacity: 0, height: 0 }} - transition={{ duration: 0.3 }} + transition={{ duration: shouldReduceMotion ? 0 : 0.3 }} className="border-b border-border bg-background/95 backdrop-blur-xl md:hidden" >
diff --git a/src/components/landing/Pricing.tsx b/src/components/landing/Pricing.tsx index 80860bccf..142315122 100644 --- a/src/components/landing/Pricing.tsx +++ b/src/components/landing/Pricing.tsx @@ -47,7 +47,7 @@ const Pricing = () => { whileInView={{ opacity: 1, y: 0 }} transition={{ duration: 0.5 }} viewport={{ once: true }} - className="rounded-2xl border border-border/70 bg-card/80 p-7 shadow-sm backdrop-blur transition-all duration-300 hover:-translate-y-1 hover:shadow-lg" + className="rounded-2xl border border-border/70 bg-card/80 p-7 shadow-sm backdrop-blur transition-all duration-300 hover:-translate-y-1 hover:shadow-lg motion-reduce:hover:translate-y-0" >

{t('pricing.trial')}

@@ -80,7 +80,7 @@ const Pricing = () => { whileInView={{ opacity: 1, y: 0 }} transition={{ duration: 0.5, delay: 0.1 }} viewport={{ once: true }} - className="relative rounded-2xl border-2 border-primary/45 bg-card/85 p-7 shadow-xl backdrop-blur ring-1 ring-primary/10 transition-all duration-300 hover:-translate-y-1" + className="relative rounded-2xl border-2 border-primary/45 bg-card/85 p-7 shadow-xl backdrop-blur ring-1 ring-primary/10 transition-all duration-300 hover:-translate-y-1 motion-reduce:hover:translate-y-0" >
diff --git a/src/components/landing/WhatsNew.tsx b/src/components/landing/WhatsNew.tsx index ee6263128..228fbea74 100644 --- a/src/components/landing/WhatsNew.tsx +++ b/src/components/landing/WhatsNew.tsx @@ -42,11 +42,11 @@ const WhatsNew = () => { whileInView={{ opacity: 1, y: 0 }} transition={{ duration: 0.45, delay: index * 0.08 }} viewport={{ once: true }} - className="group rounded-2xl border border-border/70 bg-card/75 p-6 shadow-sm backdrop-blur-sm transition-all duration-300 hover:-translate-y-1 hover:shadow-lg" + className="group rounded-2xl border border-border/70 bg-card/75 p-6 shadow-sm backdrop-blur-sm transition-all duration-300 hover:-translate-y-1 hover:shadow-lg motion-reduce:hover:translate-y-0" >
- +
{update.badge} diff --git a/src/components/layout/AppLayout.tsx b/src/components/layout/AppLayout.tsx index a1eed638a..f532b1244 100644 --- a/src/components/layout/AppLayout.tsx +++ b/src/components/layout/AppLayout.tsx @@ -1,6 +1,6 @@ // src/components/layout/AppLayout.tsx import { useState } from 'react'; -import { Outlet } from 'react-router-dom'; +import { Link, Outlet } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { Home, @@ -49,14 +49,14 @@ export function AppLayout() { {/* Header */}
- + setMobileMenuOpen(false)} className="flex items-center gap-3 shrink-0">
DebateLab

DebateLab

-
+ {/* Desktop Nav */}
{/* Main Content */} -
+
@@ -138,8 +138,8 @@ export function AppLayout() {

DebateLab

- {t('footer.terms')} - {t('footer.privacy')} + {t('footer.terms')} + {t('footer.privacy')}
diff --git a/src/components/ui/animated-section.tsx b/src/components/ui/animated-section.tsx index de4d9b3bf..2cc7a0d38 100644 --- a/src/components/ui/animated-section.tsx +++ b/src/components/ui/animated-section.tsx @@ -34,7 +34,7 @@ interface StaggeredContainerProps { className?: string; } -export function StaggeredContainer({ children, staggerMs = 200, className }: StaggeredContainerProps) { +export function StaggeredContainer({ children, staggerMs = 60, className }: StaggeredContainerProps) { // Unique per-instance class so the generated nth-child animation-delay // rules below don't leak across multiple StaggeredContainers on one page. const instanceClass = `staggered-container-${useId().replace(/:/g, '')}`; @@ -46,7 +46,7 @@ export function StaggeredContainer({ children, staggerMs = 200, className }: Sta .${instanceClass} > * { opacity: 0; transform: translateY(8px); - animation: stagger-in 0.3s ease-out forwards; + animation: stagger-in 0.2s ease-out forwards; } ${Array.from({ length: 10 }, (_, i) => `.${instanceClass} > *:nth-child(${i + 1}) { animation-delay: ${i * staggerMs}ms; }`).join('\n')} @keyframes stagger-in { diff --git a/src/components/ui/card.tsx b/src/components/ui/card.tsx index a659bf811..e93535505 100644 --- a/src/components/ui/card.tsx +++ b/src/components/ui/card.tsx @@ -3,7 +3,7 @@ import * as React from "react"; import { cn } from "@/lib/utils"; const Card = React.forwardRef>(({ className, ...props }, ref) => ( -
+
)); Card.displayName = "Card"; diff --git a/src/components/ui/empty-state.tsx b/src/components/ui/empty-state.tsx index 8e80862b0..910d0e30d 100644 --- a/src/components/ui/empty-state.tsx +++ b/src/components/ui/empty-state.tsx @@ -12,7 +12,7 @@ interface EmptyStateProps { export function EmptyState({ icon: Icon, title, description, actionLabel, onAction }: EmptyStateProps) { return (
- +

{title}

{description &&

{description}

} {actionLabel && onAction && ( diff --git a/src/components/ui/loading-spinner.tsx b/src/components/ui/loading-spinner.tsx index 4ca2c7d42..4f2deb3d2 100644 --- a/src/components/ui/loading-spinner.tsx +++ b/src/components/ui/loading-spinner.tsx @@ -29,7 +29,7 @@ interface LoadingOverlayProps { export function LoadingOverlay({ message = 'Processing...' }: LoadingOverlayProps) { return (
-
+

{message}

diff --git a/src/components/ui/section-card.tsx b/src/components/ui/section-card.tsx index 8ea08896e..d14b98874 100644 --- a/src/components/ui/section-card.tsx +++ b/src/components/ui/section-card.tsx @@ -27,7 +27,7 @@ export function SectionCard({ }: SectionCardProps) { const { t } = useTranslation(); return ( - + {isLoading && }
diff --git a/src/components/ui/streaming-indicator.tsx b/src/components/ui/streaming-indicator.tsx index 878ff6061..978f5f2de 100644 --- a/src/components/ui/streaming-indicator.tsx +++ b/src/components/ui/streaming-indicator.tsx @@ -48,7 +48,7 @@ export function StreamingIndicator({ messages, intervalMs = 3000, className }: S
- +
diff --git a/src/index.css b/src/index.css index 3c452de1d..b35c9ea38 100644 --- a/src/index.css +++ b/src/index.css @@ -371,11 +371,7 @@ All colors MUST be HSL. } .motion-loading-shell { - animation: motion-fade-in var(--motion-duration-normal) var(--motion-ease-fluid) both; - } - - .motion-loading-card { - animation: motion-float-card 2.4s var(--motion-ease-fluid) infinite; + animation: motion-fade-in var(--motion-duration-fast) var(--motion-ease-fluid) both; } .motion-loading-bar { @@ -569,22 +565,6 @@ All colors MUST be HSL. } } -/* Page entrance animation */ -.page-enter { - animation: page-enter var(--motion-duration-slow) var(--motion-ease-fluid) both; -} - -@keyframes page-enter { - from { - opacity: 0; - transform: translateY(6px); - } - to { - opacity: 1; - transform: translateY(0); - } -} - /* Staggered list entrance */ .stagger-enter > * { animation: stagger-item var(--motion-duration-normal) var(--motion-ease-fluid) both; @@ -1040,15 +1020,6 @@ button:active > svg.lucide-heart { } } -@keyframes motion-float-card { - 0%, 100% { - transform: translateY(0); - } - 50% { - transform: translateY(-4px); - } -} - @keyframes motion-loading-bar { 0% { transform: translateX(-45%) scaleX(0.2); @@ -1227,10 +1198,9 @@ button:active > svg.lucide-heart { *, *::before, *::after { - animation-duration: 0.001ms !important; - animation-iteration-count: 1 !important; + animation: none !important; scroll-behavior: auto !important; - transition-duration: 0.001ms !important; + transition: none !important; } .motion-press, @@ -1241,10 +1211,12 @@ button:active > svg.lucide-heart { .motion-reveal, .motion-swap, .motion-expand, - .motion-loading-card, .motion-loading-bar, .motion-ping-ring, - .motion-shimmer::after { + .motion-shimmer::after, + .card-hover-glow:hover, + .feature-card:hover, + .hover-lift:hover { transform: none !important; } } diff --git a/src/lib/motion-policy.ts b/src/lib/motion-policy.ts new file mode 100644 index 000000000..4fc65aaab --- /dev/null +++ b/src/lib/motion-policy.ts @@ -0,0 +1,15 @@ +export const ROUTE_TRANSITION_DURATION_SECONDS = 0.16; + +const ROUTE_EASE = [0.22, 1, 0.36, 1] as const; + +export function getRouteMotion(shouldReduceMotion: boolean) { + return { + initial: shouldReduceMotion ? { opacity: 1 } : { opacity: 0, y: 6, scale: 0.998 }, + animate: { opacity: 1, y: 0, scale: 1 }, + exit: shouldReduceMotion ? { opacity: 1 } : { opacity: 0, y: -4, scale: 0.998 }, + transition: { + duration: shouldReduceMotion ? 0 : ROUTE_TRANSITION_DURATION_SECONDS, + ease: ROUTE_EASE, + }, + }; +} diff --git a/src/pages/AttackPage.tsx b/src/pages/AttackPage.tsx index f8ae1170f..70fd91894 100644 --- a/src/pages/AttackPage.tsx +++ b/src/pages/AttackPage.tsx @@ -119,13 +119,13 @@ export default function AttackPage() { {/* Results */} - +
{attackLoading ? (
) : attackResult ? ( -
+
) : (
-
⚔️
+
⚔️

{t('attack.emptyTitle')}

{t('attack.emptyDesc')}

)} - +
diff --git a/src/pages/BrainstormPage.tsx b/src/pages/BrainstormPage.tsx index 63ccff2a5..3b1c1fd10 100644 --- a/src/pages/BrainstormPage.tsx +++ b/src/pages/BrainstormPage.tsx @@ -110,25 +110,25 @@ export default function BrainstormPage() {
{/* Results */} - +
{brainstormLoading ? (
) : brainstormResult ? ( -
+
) : (
-
💡
+
💡

{t('brainstorm.emptyTitle')}

{t('brainstorm.emptyDesc')}

)} - +
diff --git a/src/pages/CrossfirePage.tsx b/src/pages/CrossfirePage.tsx index 820725eec..e49ba8e08 100644 --- a/src/pages/CrossfirePage.tsx +++ b/src/pages/CrossfirePage.tsx @@ -250,6 +250,12 @@ export default function CrossfirePage() { undefined, () => restoreState(user?.id, importedPrefill), ); + const previousPhaseRef = useRef(state.phase); + const phaseChanged = previousPhaseRef.current !== state.phase; + useEffect(() => { + previousPhaseRef.current = state.phase; + }, [state.phase]); + const stateMotionClass = phaseChanged ? 'motion-swap' : undefined; const [draftTurn, setDraftTurn] = useState(''); const [now, setNow] = useState(Date.now()); const [formError, setFormError] = useState(''); @@ -442,7 +448,7 @@ export default function CrossfirePage() { if (state.phase === 'setup') { return ( -
+
@@ -648,7 +654,7 @@ export default function CrossfirePage() { if (state.phase === 'complete') { return ( -
+
@@ -659,7 +665,7 @@ export default function CrossfirePage() { if (state.phase === 'debriefing') { return ( -
+
@@ -697,7 +703,7 @@ export default function CrossfirePage() { const inputKind = expectedUserTurnKind(state); const remaining = state.endsAt - now; return ( -
+
diff --git a/src/pages/FactoidsPage.tsx b/src/pages/FactoidsPage.tsx index dd5408816..320bb4cf9 100644 --- a/src/pages/FactoidsPage.tsx +++ b/src/pages/FactoidsPage.tsx @@ -121,13 +121,13 @@ export default function FactoidsPage() { {/* Results */} - +
{factoidsLoading ? (
) : factoidsResult ? ( -
+
) : (
-
📚
+
📚

{t('factoids.emptyTitle')}

{t('factoids.emptyDesc')}

)} - +
diff --git a/src/pages/GeneratePage.tsx b/src/pages/GeneratePage.tsx index bf8261f89..bbe58dd2a 100644 --- a/src/pages/GeneratePage.tsx +++ b/src/pages/GeneratePage.tsx @@ -1,5 +1,5 @@ // src/pages/GeneratePage.tsx -import { useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { useSessionStore } from '@/lib/stores/session-store'; @@ -22,6 +22,7 @@ import { Save } from 'lucide-react'; import { StreamingIndicator } from '@/components/ui/streaming-indicator'; import { AnimatedSection } from '@/components/ui/animated-section'; import { prefillFromGenerate } from '@/lib/crossfire/prefill'; +import { useReducedMotion } from 'framer-motion'; export default function GeneratePage() { const { generateResult, generateLoading, generateForm, setGenerateResult, setGenerateLoading, saveCurrentCase, addAskMessage, setAskLoading, askMessages, setPendingFlowchartData, autosavedCases } = useSessionStore(); @@ -32,7 +33,18 @@ export default function GeneratePage() { const navigate = useNavigate(); const subError = useSubscriptionError(); const { t } = useTranslation(); + const shouldReduceMotion = useReducedMotion(); const [saveDialogOpen, setSaveDialogOpen] = useState(false); + const [successfulGenerationSequence, setSuccessfulGenerationSequence] = useState(0); + const resultsAnchorRef = useRef(null); + + useEffect(() => { + if (successfulGenerationSequence === 0) return; + const timer = window.setTimeout(() => { + resultsAnchorRef.current?.scrollIntoView({ behavior: shouldReduceMotion ? 'auto' : 'smooth', block: 'start' }); + }, 100); + return () => window.clearTimeout(timer); + }, [shouldReduceMotion, successfulGenerationSequence]); const [saveTitle, setSaveTitle] = useState(''); const [discoveredSources, setDiscoveredSources] = useState([]); @@ -60,12 +72,8 @@ export default function GeneratePage() { } setDiscoveredSources(sources); setGenerateResult(result); + setSuccessfulGenerationSequence((sequence) => sequence + 1); toast({ title: t('generate.complete', 'Generate complete'), description: t('generate.completeDesc', 'Your debate case is ready.'), variant: 'success' }); - // Capture the node now; the delayed callback must not touch the global - // document after this async flow may have been torn down (test and - // unmount safety). - const resultsAnchor = document.getElementById('generate-results'); - setTimeout(() => resultsAnchor?.scrollIntoView({ behavior: 'smooth', block: 'start' }), 100); } catch (error) { console.error('Failed to generate case:', error); @@ -335,13 +343,13 @@ export default function GeneratePage() {
{/* Results */} - +
{generateLoading ? (
) : generateResult ? ( -
+
) : (
-
🎯
+
🎯

{t('generate.emptyTitle')}

{t('generate.emptyDesc')}

)} - +
diff --git a/src/pages/HomePage.tsx b/src/pages/HomePage.tsx index ce899500d..79d3441dc 100644 --- a/src/pages/HomePage.tsx +++ b/src/pages/HomePage.tsx @@ -57,7 +57,7 @@ export default function HomePage() { {t('home.sparring')} {t('home.sparringDesc')}
- + @@ -77,7 +77,7 @@ export default function HomePage() {
- +
{t(`home.${mode.key}`)} {t(`home.${mode.key}Desc`)} diff --git a/src/pages/SparringPage.tsx b/src/pages/SparringPage.tsx index ea13dd36e..d3c1721d9 100644 --- a/src/pages/SparringPage.tsx +++ b/src/pages/SparringPage.tsx @@ -28,6 +28,13 @@ export default function SparringPage() { const { t } = useTranslation(); const navigate = useNavigate(); const session = useSparringStore((state) => state.currentSession); + const currentView = !session ? 'setup' : session.phase === 'complete' && session.scorecard ? 'scorecard' : 'round'; + const previousViewRef = useRef(currentView); + const viewChanged = previousViewRef.current !== currentView; + useEffect(() => { + previousViewRef.current = currentView; + }, [currentView]); + const stateMotionClass = viewChanged ? 'motion-swap' : undefined; const startSession = useSparringStore((state) => state.startSession); const submitUserSpeech = useSparringStore((state) => state.submitUserSpeech); const appendAiSpeech = useSparringStore((state) => state.appendAiSpeech); @@ -222,7 +229,7 @@ export default function SparringPage() { if (!session) { return ( -
+
); @@ -230,14 +237,14 @@ export default function SparringPage() { if (session.phase === 'complete' && session.scorecard) { return ( -
+
); } return ( -
+
diff --git a/src/test/components/AnimatedSection.test.tsx b/src/test/components/AnimatedSection.test.tsx index 37aacfd42..cc6483671 100644 --- a/src/test/components/AnimatedSection.test.tsx +++ b/src/test/components/AnimatedSection.test.tsx @@ -86,10 +86,10 @@ describe('StaggeredContainer', () => { expect(div.style.getPropertyValue('--stagger-ms')).toBe('300ms'); }); - it('uses default staggerMs of 200', () => { + it('uses a short default stagger for dense result lists', () => { const { container } = render(Test); const div = container.querySelector('.staggered-container') as HTMLElement; - expect(div.style.getPropertyValue('--stagger-ms')).toBe('200ms'); + expect(div.style.getPropertyValue('--stagger-ms')).toBe('60ms'); }); it('renders style element', () => { diff --git a/src/test/motion-policy.test.ts b/src/test/motion-policy.test.ts new file mode 100644 index 000000000..a6e826ed3 --- /dev/null +++ b/src/test/motion-policy.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest'; +import { getRouteMotion, ROUTE_TRANSITION_DURATION_SECONDS } from '@/lib/motion-policy'; + +describe('motion policy', () => { + it('keeps route transitions below the 180ms limit', () => { + expect(ROUTE_TRANSITION_DURATION_SECONDS).toBeLessThan(0.18); + expect(getRouteMotion(false).transition.duration).toBe(ROUTE_TRANSITION_DURATION_SECONDS); + }); + + it('removes route movement when reduced motion is requested', () => { + const policy = getRouteMotion(true); + + expect(policy.initial).toEqual({ opacity: 1 }); + expect(policy.exit).toEqual({ opacity: 1 }); + expect(policy.transition.duration).toBe(0); + }); +}); diff --git a/src/test/pages/pages.test.tsx b/src/test/pages/pages.test.tsx index 28fe3720a..db4fb0f35 100644 --- a/src/test/pages/pages.test.tsx +++ b/src/test/pages/pages.test.tsx @@ -28,6 +28,7 @@ vi.mock('framer-motion', () => ({ AnimatePresence: ({ children }: React.PropsWithChildren) => <>{children}, useAnimation: () => ({}), useInView: () => true, + useReducedMotion: () => false, })); // Mock useAuth