diff --git a/e2e/authed-pages.spec.ts b/e2e/authed-pages.spec.ts index 37d6274a5..563a8eca0 100644 --- a/e2e/authed-pages.spec.ts +++ b/e2e/authed-pages.spec.ts @@ -13,6 +13,7 @@ const AUTH_ROUTES = [ { path: '/app/library', name: 'library' }, { path: '/app/history', name: 'history' }, { path: '/app/timer', name: 'timer' }, + { path: '/app/sparring', name: 'sparring' }, { path: '/app/flowchart', name: 'flowchart' }, { path: '/app/settings', name: 'settings' }, ] as const; diff --git a/e2e/sparring.spec.ts b/e2e/sparring.spec.ts new file mode 100644 index 000000000..51248cc69 --- /dev/null +++ b/e2e/sparring.spec.ts @@ -0,0 +1,141 @@ +import { expect, test } from '@playwright/test'; +import { setLocale, stubSupabaseSession } from './helpers/auth'; + +const corsHeaders = { + 'access-control-allow-origin': '*', + 'access-control-allow-headers': 'authorization, x-client-info, apikey, content-type', + 'access-control-allow-methods': 'POST, OPTIONS', + 'content-type': 'application/json', +}; + +const opponentSpeech = (index: number) => ({ + action: 'respond', + speech: { + content: `Opponent response ${index}: the policy needs a stronger comparative case against narrower alternatives.`, + strategy: 'Test the warrant, then weigh the alternative.', + targetedPoints: ['The comparative mechanism is still underdeveloped.'], + }, +}); + +const scorecard = { + action: 'score', + scorecard: { + userScore: 78, + aiScore: 74, + categories: [ + { name: 'Logic', userScore: 20, aiScore: 18, feedback: 'The causal chain was clear.' }, + { name: 'Evidence', userScore: 18, aiScore: 18, feedback: 'The factual warrant needed more support.' }, + { name: 'Delivery', userScore: 19, aiScore: 19, feedback: 'Judged from organization, not vocal performance.' }, + { name: 'Strategy', userScore: 21, aiScore: 19, feedback: 'The user prioritized the central clash.' }, + ], + overallFeedback: 'The user won a narrow edge by weighing the main mechanism.', + keyMoments: ['The rebuttal answered the opponent’s strongest objection.'], + improvementAreas: ['Compare the alternative policy explicitly.'], + winner: 'user', + verdict: 'The user had the edge.', + }, +}; + +test.beforeEach(async ({ page }) => { + await setLocale(page, 'en'); + await stubSupabaseSession(page); +}); + +test('completes an LD sparring round through mocked opponent turns and judging', async ({ page }) => { + let responseNumber = 0; + const actions: string[] = []; + + await page.route('**/functions/v1/ai**', async (route) => { + if (route.request().method() === 'OPTIONS') { + await route.fulfill({ status: 200, headers: corsHeaders, body: 'ok' }); + return; + } + + const body = route.request().postDataJSON() as { input?: { action?: string } }; + const action = body.input?.action ?? 'unknown'; + actions.push(action); + const output = action === 'score' ? scorecard : opponentSpeech(++responseNumber); + await route.fulfill({ status: 200, headers: corsHeaders, body: JSON.stringify({ output }) }); + }); + + await page.goto('/app/sparring', { waitUntil: 'domcontentloaded' }); + await expect(page.getByRole('heading', { name: 'Spar with an opponent who listens.' })).toBeVisible(); + + await page.getByLabel('Motion').fill('This House would make public transport free at the point of use.'); + await page.getByRole('button', { name: /Lincoln-Douglas · 5 speeches/ }).click(); + await page.getByRole('button', { name: 'Start round' }).click(); + + const userSpeeches = [ + ['Affirmative constructive', 'Universal access removes the direct price barrier to participation.'], + ['First affirmative rebuttal', 'Targeted discounts preserve eligibility gaps and administrative friction.'], + ['Second affirmative rebuttal', 'The universal policy is more reliable because every marginal trip is covered.'], + ] as const; + + for (const [index, [turnLabel, content]] of userSpeeches.entries()) { + const draft = page.getByRole('textbox', { name: `Draft for ${turnLabel}` }); + await expect(draft).toBeVisible(); + await draft.fill(content); + await page.getByRole('button', { name: 'Submit speech' }).click(); + if (turnLabel !== 'Second affirmative rebuttal') { + await expect(page.getByText(`Opponent response ${index + 1}:`)).toBeVisible(); + } + } + + await expect(page.getByText('Judge is weighing the round')).toBeVisible(); + await expect(page.getByRole('heading', { name: 'Here is what happened in the room.' })).toBeVisible(); + await expect(page.getByText('You won the ballot')).toBeVisible(); + await expect(page.getByText('The user had the edge.')).toBeVisible(); + expect(actions).toEqual(['respond', 'respond', 'score']); + + const hasHorizontalOverflow = await page.evaluate(() => document.documentElement.scrollWidth > document.documentElement.clientWidth + 1); + expect(hasHorizontalOverflow).toBe(false); +}); + +test('uses the school 4-6-6-3 roles and coaching in the default round', async ({ page }) => { + const actions: string[] = []; + await page.route('**/functions/v1/ai**', async (route) => { + if (route.request().method() === 'OPTIONS') { + await route.fulfill({ status: 200, headers: corsHeaders, body: 'ok' }); + return; + } + const body = route.request().postDataJSON() as { input?: { action?: string } }; + actions.push(body.input?.action ?? 'unknown'); + await route.fulfill({ status: 200, headers: corsHeaders, body: JSON.stringify({ output: opponentSpeech(1) }) }); + }); + + await page.goto('/app/sparring', { waitUntil: 'domcontentloaded' }); + await page.getByLabel('Motion').fill('This House would require community service for graduation.'); + await page.getByRole('button', { name: 'Start round' }).click(); + await expect.poll(() => page.evaluate(() => window.scrollY)).toBe(0); + + const introductor = page.getByRole('textbox', { name: 'Draft for Pro Introductor' }); + await expect(introductor).toBeVisible(); + await expect(page.getByText(/Open with an exordio, define the key terms/)).toBeVisible(); + await introductor.fill('Our framework is civic responsibility. We define service as supervised work that directly benefits the local community.'); + await page.getByRole('button', { name: 'Submit speech' }).click(); + + await expect(page.getByText('Opponent response 1:')).toBeVisible(); + await expect(page.getByRole('textbox', { name: 'Draft for Pro R1' })).toBeVisible(); + await expect(page.getByText(/Build the core arguments with claims, warrants, support, and impacts/)).toBeVisible(); + expect(actions).toEqual(['respond']); +}); + +test('keeps the setup usable on mobile with reduced motion', async ({ page }) => { + await page.emulateMedia({ reducedMotion: 'reduce' }); + await page.goto('/app/sparring', { waitUntil: 'domcontentloaded' }); + await expect(page.getByRole('heading', { name: 'Spar with an opponent who listens.' })).toBeVisible(); + const logo = page.locator('header img[alt="DebateLab"]'); + await expect(logo).toHaveAttribute('src', '/favicon.png'); + await expect.poll(() => logo.evaluate((image: HTMLImageElement) => image.naturalWidth)).toBeGreaterThan(0); + await expect(page.getByRole('button', { name: /School 4-6-6-3 · 8 speeches/ })).toHaveAttribute('aria-pressed', 'true'); + await expect(page.getByText('Pro Introductor')).toBeVisible(); + await expect(page.getByText('Pro Conclusor')).toBeVisible(); + + const hasHorizontalOverflow = await page.evaluate(() => document.documentElement.scrollWidth > document.documentElement.clientWidth + 1); + expect(hasHorizontalOverflow).toBe(false); + + const animationNames = await page.locator('[class*="animate-"]').evaluateAll((elements) => elements + .map((element) => getComputedStyle(element).animationName) + .filter((name) => name !== 'none')); + expect(animationNames).toEqual([]); +}); diff --git a/index.html b/index.html index f52ab99ad..798357471 100644 --- a/index.html +++ b/index.html @@ -14,6 +14,7 @@ + diff --git a/plans/README.md b/plans/README.md index 66599ccf5..f2e355957 100644 --- a/plans/README.md +++ b/plans/README.md @@ -7,7 +7,6 @@ This folder holds implementation plans for remaining DebateLab product work. Kee - [Admin announcements](./admin-announcements.md) - [Ask and sitewide search quality](./search-quality.md) - [Control honesty and deterministic toggles](./control-honesty.md) -- [Debate sparring and streaming](./sparring-and-streaming.md) - [Model cost accounting](./model-cost-accounting.md) - [Prompt evaluation sheet (manual)](./prompt-eval-sheet.md) - [Quality & Truth release checklist](./release-checklist.md) @@ -19,6 +18,7 @@ This folder holds implementation plans for remaining DebateLab product work. Kee ## Done +- [Debate sparring](./sparring-and-streaming.md) - [Bug and polish cleanup](./done/bugs.txt) - [Admin trial gifts](./done/admin-trial-gifts.md) - [Top users email fallback](./done/top-users-email-fallback.md) diff --git a/plans/sparring-and-streaming.md b/plans/sparring-and-streaming.md index bc2611e38..f6862b51c 100644 --- a/plans/sparring-and-streaming.md +++ b/plans/sparring-and-streaming.md @@ -1,5 +1,7 @@ # Debate Sparring Mode — Implementation Plan +**Status:** Implemented locally on 2026-08-31. Production migration and deployment remain separate release operations. + ## Overview A turn-based AI debate practice mode where the user argues one side and the AI argues the other, simulating a real debate round with timed speeches, rebuttals, and scoring. @@ -18,7 +20,7 @@ Add a `sparring` feature handler in `supabase/functions/ai/index.ts` or a dedica ## User Flow -1. **Setup** — User picks motion, side (Pro/Con), format (Standard/LD/PF), difficulty (Easy/Medium/Hard) +1. **Setup** — User picks motion, side (Pro/Con), format (School 4-6-6-3/LD/PF), difficulty (Easy/Medium/Hard) 2. **Round Start** — Timer begins, user types their opening speech 3. **AI Response** — AI generates opponent's speech based on user's arguments 4. **Rebuttal** — User responds to AI's points, timer running @@ -82,7 +84,18 @@ interface SparringScorecard { - Save completed sessions to History (new `savedSparring` in session store) - Export sparring transcript to Word/PDF -## Estimated Effort +## Delivered + +- The school 4-6-6-3 format as the default, with explicit Introductor, R1, R2, and Conclusor duties for both sides, plus Lincoln-Douglas and Public Forum +- Deadline-based timers, pause/resume/reset, offline-safe tab persistence, retries, and keyboard submit +- Transcript-aware opponent turns, signed AI-turn integrity, atomic/idempotent judging, and a four-category scorecard +- History, settings, navigation, home, auth reset, delete-all, DOCX/PDF export, service-worker preload, and en/es/hu integration +- Unit, integration, Deno, desktop/mobile browser, reduced-motion, overflow, and live provider-contract coverage + +Streaming remains tracked independently in `plans/streaming-ai.md`; it is not part of the turn-based sparring contract. + +## Original Estimated Effort + - Setup + Round UI: 4 hours - AI integration (prompts, edge function): 3 hours - Scorecard + judging: 2 hours diff --git a/public/favicon.png b/public/favicon.png new file mode 100644 index 000000000..984154c14 Binary files /dev/null and b/public/favicon.png differ diff --git a/public/manifest.webmanifest b/public/manifest.webmanifest index e117db5b4..ea439e148 100644 --- a/public/manifest.webmanifest +++ b/public/manifest.webmanifest @@ -9,9 +9,9 @@ "theme_color": "#0f172a", "icons": [ { - "src": "/favicon.ico", - "sizes": "48x48", - "type": "image/x-icon" + "src": "/favicon.png", + "sizes": "1024x1024", + "type": "image/png" } ] } diff --git a/public/sw.js b/public/sw.js index 3819c7ac8..96590170e 100644 --- a/public/sw.js +++ b/public/sw.js @@ -12,6 +12,7 @@ const APP_SHELL = [ '/app/ask', '/app/library', '/app/timer', + '/app/sparring', '/app/flowchart', '/app/history', '/app/settings', @@ -20,6 +21,7 @@ const APP_SHELL = [ '/theme-init.js', '/manifest.webmanifest', '/favicon.ico', + '/favicon.png', '/hero-visual.png', ]; diff --git a/src/App.tsx b/src/App.tsx index 8324d6a1a..b1f134914 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -30,6 +30,7 @@ const SettingsPage = lazy(() => import("./pages/SettingsPage")); const HistoryPage = lazy(() => import("./pages/HistoryPage")); const LibraryPage = lazy(() => import("./pages/LibraryPage")); const TimerPage = lazy(() => import("./pages/TimerPage")); +const SparringPage = lazy(() => import("./pages/SparringPage")); const AdminPage = lazy(() => import("./pages/AdminPage")); const SharedPage = lazy(() => import("./pages/SharedPage")); const FlowchartPage = lazy(() => import("./pages/FlowchartPage")); @@ -123,6 +124,7 @@ function AnimatedRoutes() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/src/components/features/sparring/SparringRound.tsx b/src/components/features/sparring/SparringRound.tsx new file mode 100644 index 000000000..e08af424f --- /dev/null +++ b/src/components/features/sparring/SparringRound.tsx @@ -0,0 +1,294 @@ +import { useEffect, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { AlertCircle, Bot, Check, ChevronRight, CirclePause, CirclePlay, Clock3, CloudOff, RotateCcw, Send, UserRound, Wifi } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Textarea } from '@/components/ui/textarea'; +import { Badge } from '@/components/ui/badge'; +import { Progress } from '@/components/ui/progress'; +import { Markdown } from '@/components/ui/markdown'; +import { cn } from '@/lib/utils'; +import { formatRoundTime, type SparringTurnKind } from '@/lib/sparring/formats'; +import type { SparringSession } from '@/lib/stores/sparring-store'; +import { useSparringStore } from '@/lib/stores/sparring-store'; + +interface SparringRoundProps { + session: SparringSession; + aiLoading?: boolean; + aiError?: string | null; + judging?: boolean; + judgingError?: string | null; + submissionError?: string | null; + onSubmit: (content: string) => void; + onRetryAi?: () => void; + onRetryJudging?: () => void; +} + +function wordCount(value: string) { + const trimmed = value.trim(); + return trimmed ? trimmed.split(/\s+/).length : 0; +} + +const SCHOOL_ROLE_GUIDANCE: Partial> = { + introductor: 'Open with an exordio, define the key terms, set the framework and thesis, then give the roadmap. Save the full arguments for R1.', + r1: 'Build the core arguments with claims, warrants, support, and impacts. Use the final part to anticipate or answer the strongest opposition.', + r2: 'Extend R1, answer the opponent directly, identify the central clashes, and compare the impacts. Do not simply repeat the case.', + conclusor: 'Add no new arguments. Crystallize the decisive clashes, weigh the round, and close with the reason your side wins.', +}; + +export function SparringRound({ + session, + aiLoading = false, + aiError, + judging = false, + judgingError, + submissionError, + onSubmit, + onRetryAi, + onRetryJudging, +}: SparringRoundProps) { + const { t } = useTranslation(); + const setDraft = useSparringStore((state) => state.setDraft); + const tickTimer = useSparringStore((state) => state.tickTimer); + const pauseTimer = useSparringStore((state) => state.pauseTimer); + const resumeTimer = useSparringStore((state) => state.resumeTimer); + const resetTurnTimer = useSparringStore((state) => state.resetTurnTimer); + const [isOnline, setIsOnline] = useState(() => typeof navigator === 'undefined' || navigator.onLine); + const currentTurn = session.turns[session.currentTurnIndex]; + const currentWordCount = wordCount(session.draft); + const totalWords = useMemo(() => session.speeches.reduce((sum, speech) => sum + wordCount(speech.content), 0), [session.speeches]); + const userTurn = currentTurn?.speaker === 'user'; + const roleGuidance = currentTurn && SCHOOL_ROLE_GUIDANCE[currentTurn.kind] + ? t(`sparring.roleGuidance.${currentTurn.kind}`, SCHOOL_ROLE_GUIDANCE[currentTurn.kind]) + : t('sparring.round.yourSpeechDescription', 'Make the case in the time you have. The opponent will respond to this text.'); + const timerProgress = currentTurn?.timeLimitSeconds ? Math.max(0, Math.min(100, (session.timerRemainingSeconds / currentTurn.timeLimitSeconds) * 100)) : 0; + const isUrgent = session.timerRemainingSeconds <= 30 && session.timerRemainingSeconds > 0; + const timeExpired = session.timerRemainingSeconds <= 0 + || (session.timerDeadline !== null && session.timerDeadline <= Date.now()); + + useEffect(() => { + const handleOnline = () => setIsOnline(true); + const handleOffline = () => setIsOnline(false); + window.addEventListener('online', handleOnline); + window.addEventListener('offline', handleOffline); + return () => { + window.removeEventListener('online', handleOnline); + window.removeEventListener('offline', handleOffline); + }; + }, []); + + useEffect(() => { + if (!userTurn || !session.timerRunning) return; + const intervalId = window.setInterval(() => tickTimer(), 250); + return () => window.clearInterval(intervalId); + }, [session.timerRunning, tickTimer, userTurn]); + + const handleKeyDown = (event: React.KeyboardEvent) => { + if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') { + event.preventDefault(); + if (userTurn && session.draft.trim() && !judging && isOnline && !timeExpired) onSubmit(session.draft); + } + }; + + return ( +
+
+
+
+ {t(`sparring.format.${session.format}`, session.format.toUpperCase())} + {t('sparring.round.inProgress', 'Round in progress')} + + {session.userSide === 'pro' ? t('sparring.round.youArePro', 'You are Pro') : t('sparring.round.youAreCon', 'You are Con')} +
+

{session.motion}

+
+
+ {isOnline ?
+
+ +
+
+ + +
+
+

{judging ? t('sparring.round.complete', 'Round complete') : currentTurn ? t('sparring.round.turnOf', 'Turn {{current}} of {{total}}', { current: session.currentTurnIndex + 1, total: session.turns.length }) : t('sparring.round.preparingResult', 'Preparing your result')}

+

{judging ? t('sparring.round.judgeReading', 'The judge is reading the transcript.') : currentTurn ? t(`sparring.turns.${currentTurn.id}`, currentTurn.label) : t('sparring.round.scoring', 'Scoring the round')}

+
+
+
+
+ div]:bg-amber-500' : '[&>div]:bg-primary')} aria-label={t('sparring.round.timeRemaining', 'Time remaining')} /> + {!judging && userTurn && ( +
+ + + {t('sparring.round.clockNote', 'The clock uses the saved deadline, not tab focus.')} +
+ )} +
+
+ + {judging ? ( + + + {judgingError ? ( + <> +
+

{t('sparring.round.scoreFailed', 'Could not score this round')}

+

{judgingError}

+ + + ) : ( + <> + +

{t('sparring.round.judgeWeighing', 'Judge is weighing the round')}

+

{t('sparring.round.judgeDescription', 'Reading the claims, responses, and strategic choices. This usually takes one request.')}

+ + )} +
+
+ ) : ( + <> + {userTurn ? ( + + +
+
+ +

{roleGuidance}

+
+ {t('sparring.round.wordCount', '{{count}} words', { count: currentWordCount })} +
+
+ +