Skip to content
Merged
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
17 changes: 17 additions & 0 deletions .audit/animation-polish.tsv
Original file line number Diff line number Diff line change
@@ -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
128 changes: 128 additions & 0 deletions e2e/animation-polish.spec.ts
Original file line number Diff line number Diff line change
@@ -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 });
});
41 changes: 22 additions & 19 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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"));
Expand Down Expand Up @@ -78,7 +79,7 @@ function getToolForPathname(pathname: string): ToolName | undefined {
// Loading fallback component
const PageLoader = () => (
<div className="motion-loading-shell flex min-h-[60vh] items-center justify-center">
<div className="motion-loading-card flex flex-col items-center gap-4 rounded-lg border bg-card/80 px-8 py-7 shadow-lg">
<div className="flex flex-col items-center gap-4 rounded-lg border bg-card/80 px-8 py-7 shadow-lg">
<div className="relative flex h-12 w-12 items-center justify-center">
<span className="motion-ping-ring absolute h-full w-full rounded-full border border-primary/40" />
<Loader2 className="h-8 w-8 animate-spin text-primary" />
Expand Down Expand Up @@ -131,19 +132,20 @@ export function ToolRouteAnalytics(): null {
function AnimatedRoutes() {
const location = useLocation();
const shouldReduceMotion = useReducedMotion();
const routeMotion = getRouteMotion(Boolean(shouldReduceMotion));
useSessionStorageSync();

return (
<AnimatePresence mode="wait" initial={false}>
<motion.div
key={location.pathname}
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 : 0.16, ease: [0.22, 1, 0.36, 1] }}
{...routeMotion}
data-reduced-motion={shouldReduceMotion ? "true" : "false"}
data-route-transition-ms={ROUTE_TRANSITION_DURATION_SECONDS * 1000}
className="min-h-screen motion-reduce:transform-none"
>
<Routes location={location}>
<Suspense fallback={<PageLoader />}>
<Routes location={location}>
{/* Public Routes */}
<Route path="/" element={<LandingPage />} />
<Route path="/terms" element={<TermsPage />} />
Expand Down Expand Up @@ -178,7 +180,8 @@ function AnimatedRoutes() {
<Route path="/app/admin" element={<AdminPage />} />
</Route>
<Route path="*" element={<NotFound />} />
</Routes>
</Routes>
</Suspense>
</motion.div>
</AnimatePresence>
);
Expand All @@ -188,18 +191,18 @@ const App = () => (
<QueryClientProvider client={queryClient}>
<AuthProvider>
<ThemeProvider>
<TooltipProvider delayDuration={200}>
<Toaster />
<Sonner />
<OfflineIndicator />
<BrowserRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
<RouteErrorBoundary>
<Suspense fallback={<PageLoader />}>
<MotionConfig reducedMotion="user">
<TooltipProvider delayDuration={200}>
<Toaster />
<Sonner />
<OfflineIndicator />
<BrowserRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
<RouteErrorBoundary>
<AnimatedRoutes />
</Suspense>
</RouteErrorBoundary>
</BrowserRouter>
</TooltipProvider>
</RouteErrorBoundary>
</BrowserRouter>
</TooltipProvider>
</MotionConfig>
</ThemeProvider>
</AuthProvider>
</QueryClientProvider>
Expand Down
6 changes: 4 additions & 2 deletions src/components/features/ask/AskChat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -368,14 +369,15 @@ export function AskChat({ messages, isLoading, onSend, onClear, onSaveChat, save
const fileInputRef = useRef<HTMLInputElement>(null);
const messagesEndRef = useRef<HTMLDivElement>(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(() => {
Expand Down
9 changes: 8 additions & 1 deletion src/components/features/attack/AttackForm.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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<HTMLDivElement>(null);

useEffect(() => {
if (advancedOptionsRef.current) advancedOptionsRef.current.inert = !optionsOpen;
}, [optionsOpen]);
const { register, handleSubmit, setValue, watch, formState: { errors } } = useForm<AttackInput>({
resolver: zodResolver(AttackInputSchema),
defaultValues: {
Expand Down Expand Up @@ -114,9 +119,11 @@ export function AttackForm({ onSubmit, isLoading, importedCase }: AttackFormProp
</div>

<div
ref={advancedOptionsRef}
id="attack-options"
className="motion-expand"
data-open={optionsOpen}
aria-hidden={!optionsOpen}
>
<div className="space-y-4 pt-1">
{/* Settings Grid */}
Expand Down
9 changes: 8 additions & 1 deletion src/components/features/generate/GenerateForm.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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<HTMLDivElement>(null);

useEffect(() => {
if (advancedOptionsRef.current) advancedOptionsRef.current.inert = !advancedOpen;
}, [advancedOpen]);

const { register, handleSubmit, setValue, watch, formState: { errors } } = useForm<GenerateInput>({
resolver: zodResolver(GenerateInputSchema),
Expand Down Expand Up @@ -198,9 +203,11 @@ export function GenerateForm({ onSubmit, isLoading }: GenerateFormProps) {
</div>

<div
ref={advancedOptionsRef}
id="generate-more-options"
className="motion-expand"
data-open={advancedOpen}
aria-hidden={!advancedOpen}
>
<div className="grid gap-4 pt-1 sm:grid-cols-3">
{/* Length */}
Expand Down
Loading
Loading