diff --git a/docs/architecture.md b/docs/architecture.md index 7381e903f..9f9f18ed6 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -83,7 +83,7 @@ Supabase Auth owns user sessions. Protected routes provide a client navigation b Checkout and portal functions create Stripe sessions after authenticated ownership and redirect validation. The webhook updates subscription state and records event processing for idempotency. AI handlers record provider usage and calculated cost using shared pricing logic. -All model requests use the single OpenRouter backend route and `OPENROUTER_API_KEY`. The only selectable and default model is `z-ai/glm-5.3-flash`; the shared allowlist and request controls live in [`supabase/functions/_shared/model-catalog.ts`](../supabase/functions/_shared/model-catalog.ts). OpenRouter usage remains raw in the audit row, and the reported charge is unknown when the provider does not return a complete cost. +All model requests use the single OpenRouter backend route and `OPENROUTER_API_KEY`. The shared ten-model catalog, Luna Basic default, Pro-only entries, and request controls live in [`supabase/functions/_shared/model-catalog.ts`](../supabase/functions/_shared/model-catalog.ts). OpenRouter usage remains raw in the audit row, and the reported charge is unknown when the provider does not return a complete cost. Historical provider pricing evidence is retained under [`docs/pricing-snapshots/`](pricing-snapshots/). Those snapshots support legacy calculator records and are evidence, not runtime configuration for the OpenRouter route. diff --git a/e2e/authed-pages.spec.ts b/e2e/authed-pages.spec.ts index 1b6e28ec3..4ef7d642e 100644 --- a/e2e/authed-pages.spec.ts +++ b/e2e/authed-pages.spec.ts @@ -55,6 +55,40 @@ for (const locale of LOCALES) { }); } +test('aligns Factoids timeframe and model controls responsively', async ({ page }) => { + await setLocale(page, 'en'); + await stubSupabaseSession(page); + await page.goto('/app/factoids', { waitUntil: 'domcontentloaded' }); + + const timeframe = page.locator('#timeframe'); + const model = page.getByRole('combobox', { name: /Select AI model for factoids/i }); + await expect(timeframe).toBeVisible(); + await expect(model).toBeVisible(); + const layout = await page.evaluate(() => { + const timeframeElement = document.querySelector('#timeframe'); + const modelElement = document.querySelector('[aria-label*="Select AI model for factoids"]'); + if (!timeframeElement || !modelElement) throw new Error('Factoids alignment controls are missing.'); + const timeframeBox = timeframeElement.getBoundingClientRect(); + const modelBox = modelElement.getBoundingClientRect(); + return { + viewportWidth: window.innerWidth, + timeframe: { top: timeframeBox.top, bottom: timeframeBox.bottom, left: timeframeBox.left }, + model: { top: modelBox.top, left: modelBox.left }, + horizontalOverflow: document.documentElement.scrollWidth > document.documentElement.clientWidth, + }; + }); + + expect(layout.horizontalOverflow).toBe(false); + // FactoidsForm switches its settings grid at Tailwind `sm` (640px). + if (layout.viewportWidth >= 640) { + expect(Math.abs(layout.timeframe.top - layout.model.top)).toBeLessThanOrEqual(1); + expect(layout.model.left).toBeGreaterThan(layout.timeframe.left); + } else { + expect(layout.model.top).toBeGreaterThanOrEqual(layout.timeframe.bottom); + expect(Math.abs(layout.timeframe.left - layout.model.left)).toBeLessThanOrEqual(1); + } +}); + test('visible Ask advanced controls use the non-streaming contract', async ({ page }) => { await setLocale(page, 'en'); await stubSupabaseSession(page); diff --git a/e2e/home-navigation.spec.ts b/e2e/home-navigation.spec.ts new file mode 100644 index 000000000..a5c3231ed --- /dev/null +++ b/e2e/home-navigation.spec.ts @@ -0,0 +1,142 @@ +import { test, expect } from '@playwright/test'; +import { setLocale, stubSupabaseSession } from './helpers/auth'; + +test.beforeEach(async ({ page }) => { + await setLocale(page, 'en'); + await stubSupabaseSession(page); + await page.addInitScript(() => { + window.localStorage.setItem('debate-trainer-onboarding', JSON.stringify({ + state: { hasCompletedOnboarding: true, isTourRunning: false }, + version: 0, + })); + }); +}); + +test('home groups the eight main tools and keeps utility routes visible', async ({ page }) => { + await page.goto('/app', { waitUntil: 'domcontentloaded' }); + + const main = page.locator('main'); + await expect(main.getByRole('heading', { name: 'DebateLab' })).toBeVisible({ timeout: 15_000 }); + await expect(main.getByRole('heading', { name: 'Prepare' })).toBeVisible(); + await expect(main.getByRole('heading', { name: 'Practice' })).toBeVisible(); + await expect(main.getByRole('heading', { name: 'Utilities' })).toBeVisible(); + await expect(page.getByText('Choose a mode to start.')).toHaveCount(0); + + for (const href of ['/app/generate', '/app/brainstorm', '/app/factoids', '/app/flowchart', '/app/attack', '/app/ask', '/app/crossfire', '/app/sparring']) { + await expect(page.locator('main').locator(`a[href="${href}"]`)).toBeVisible(); + } + for (const href of ['/app/library', '/app/timer', '/app/history']) { + await expect(page.locator('main').locator(`a[href="${href}"]`)).toBeVisible(); + } +}); + +test('onboarding follows visible home targets when secondary navigation is closed', async ({ page }) => { + await page.addInitScript(() => { + window.localStorage.setItem('debate-trainer-onboarding', JSON.stringify({ + state: { hasCompletedOnboarding: false, isTourRunning: false }, + version: 0, + })); + }); + await page.goto('/app', { waitUntil: 'domcontentloaded' }); + + const desktopMoreMenu = page.locator('details').filter({ has: page.locator('summary[aria-label="More"]') }); + if ((page.viewportSize()?.width ?? 0) >= 1024) { + await expect(desktopMoreMenu).not.toHaveAttribute('open'); + } + + await expect(page.locator('.react-joyride__tooltip')).toBeVisible({ timeout: 15_000 }); + await page.locator('[data-test-id="button-primary"]').click(); + + for (const key of ['generate', 'attack', 'brainstorm', 'factoids', 'ask', 'crossfire', 'sparring', 'library', 'timer', 'flowchart', 'history']) { + await expect(page.locator(`[data-tour="mode-${key}"]`)).toBeVisible(); + await expect.poll(() => page.evaluate((targetKey) => { + const spotlight = document.querySelector('[data-test-id="spotlight"]'); + if (!(spotlight instanceof HTMLElement)) return false; + + const spotlightWidth = Number.parseFloat(spotlight.style.width); + const spotlightHeight = Number.parseFloat(spotlight.style.height); + return Array.from(document.querySelectorAll( + `[data-tour="mode-${targetKey}"], [data-tour="nav-${targetKey}"]`, + )).some((target) => { + const style = window.getComputedStyle(target); + const rect = target.getBoundingClientRect(); + return style.display !== 'none' + && style.visibility !== 'hidden' + && target.getClientRects().length > 0 + && rect.width > 0 + && rect.height > 0 + && Math.abs(spotlightWidth - rect.width - 20) < 2 + && Math.abs(spotlightHeight - rect.height - 20) < 2; + }); + }, key)).toBe(true); + await page.locator('[data-test-id="button-primary"]').click(); + } + + await page.locator('[data-test-id="button-skip"]').click(); +}); + +test('header keeps the brand and controls inside a 320px viewport', async ({ page }) => { + await page.setViewportSize({ width: 320, height: 844 }); + await page.goto('/app', { waitUntil: 'domcontentloaded' }); + await expect(page.getByRole('heading', { name: 'Prepare' })).toBeVisible({ timeout: 15_000 }); + + const languageControl = page.getByRole('button', { name: 'Language' }); + const themeControl = page.getByRole('button', { name: 'Toggle theme' }); + const settingsControl = page.getByRole('link', { name: 'Settings' }); + const menuControl = page.getByRole('button', { name: 'Open menu' }); + const controls = [languageControl, themeControl, settingsControl, menuControl]; + + await expect(page.locator('header img[alt="DebateLab"]')).toBeVisible(); + for (const control of controls) await expect(control).toBeVisible(); + + const boxes = await Promise.all(controls.map((control) => control.boundingBox())); + expect(boxes.every((box) => box && box.x >= 0 && box.x + box.width <= 320)).toBe(true); + expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBe(320); +}); + +test('desktop More menu exposes secondary destinations without horizontal scrolling', async ({ page }) => { + test.skip((page.viewportSize()?.width ?? 0) < 1024, 'Desktop-only More menu check'); + await page.goto('/app', { waitUntil: 'domcontentloaded' }); + await page.locator('summary[aria-label="More"]').click(); + + const moreMenu = page.locator('details').filter({ has: page.locator('summary[aria-label="More"]') }); + for (const [label, href] of [ + ['Brainstorm', '/app/brainstorm'], + ['Factoids', '/app/factoids'], + ['Flowchart', '/app/flowchart'], + ['Library', '/app/library'], + ['Timer', '/app/timer'], + ['History', '/app/history'], + ] as const) { + await expect(moreMenu.getByRole('link', { name: label })).toHaveAttribute('href', href); + } + expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(await page.evaluate(() => window.innerWidth)); +}); + +test('desktop header fits without internal or document overflow at supported widths', async ({ page }) => { + for (const width of [1024, 1280, 1440, 1920]) { + await page.setViewportSize({ width, height: 900 }); + await page.goto('/app', { waitUntil: 'domcontentloaded' }); + await expect(page.getByRole('heading', { name: 'Prepare' })).toBeVisible({ timeout: 15_000 }); + + const navigation = page.getByRole('navigation', { name: 'Main navigation' }); + await expect(navigation).toBeVisible(); + expect(await navigation.evaluate((element) => element.scrollWidth <= element.clientWidth)).toBe(true); + expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true); + } +}); + +test('mobile menu exposes every tool destination and closes after navigation', async ({ page }) => { + await page.setViewportSize({ width: 390, height: 844 }); + await page.goto('/app', { waitUntil: 'domcontentloaded' }); + await page.getByRole('button', { name: 'Open menu' }).click(); + + const mobileNavigation = page.getByRole('navigation', { name: 'Mobile navigation' }); + for (const label of ['Home', 'Generate', 'Attack', 'Brainstorm', 'Factoids', 'Ask', 'Crossfire', 'Library', 'Timer', 'Sparring', 'Flowchart', 'History']) { + await expect(mobileNavigation.getByRole('link', { name: new RegExp(`^${label}`) })).toBeVisible(); + } + + await mobileNavigation.getByRole('link', { name: /^Brainstorm/ }).click(); + await expect(page).toHaveURL(/\/app\/brainstorm$/); + await expect(page.getByRole('navigation', { name: 'Mobile navigation' })).toHaveCount(0); +}); diff --git a/e2e/quality-backlog.spec.ts b/e2e/quality-backlog.spec.ts index ab3700a54..ca9eb1d74 100644 --- a/e2e/quality-backlog.spec.ts +++ b/e2e/quality-backlog.spec.ts @@ -35,6 +35,9 @@ for (const locale of locales) { .first() .click(); } else { + if (!await desktopLink.isVisible()) { + await page.locator('summary[data-tour="nav-more"]').click(); + } await desktopLink.click(); } await expect(page).toHaveURL(new RegExp(`/app/${route}$`)); diff --git a/e2e/sparring.spec.ts b/e2e/sparring.spec.ts index 13500ec7e..2b20d6622 100644 --- a/e2e/sparring.spec.ts +++ b/e2e/sparring.spec.ts @@ -62,10 +62,11 @@ test('completes an LD sparring round through mocked opponent turns and judging', }); await page.goto('/app/sparring', { waitUntil: 'domcontentloaded' }); - await expect(page.getByRole('heading', { name: 'Spar with an opponent who listens.' })).toBeVisible(); + await expect(page.getByRole('heading', { name: 'Sparring' })).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('combobox', { name: 'Round format' }).click(); + await page.getByRole('option', { name: /Lincoln-Douglas · 5 speeches/ }).click(); await page.getByRole('button', { name: 'Start round' }).click(); const userSpeeches = [ @@ -126,11 +127,24 @@ test('uses the school 4-6-6-3 roles and coaching in the default round', async ({ 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(); + await expect(page.getByRole('heading', { name: 'Sparring' })).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.getByRole('combobox', { name: 'Round format' })).toHaveText('School 4-6-6-3 · 8 speeches'); + await expect(page.getByText('Write the resolution as you would hear it in the room.')).toHaveCount(0); + await expect(page.getByText('A precise motion gives the opponent something concrete to attack.')).toHaveCount(0); + await expect(page.getByText('Pro Introductor')).toBeHidden(); + const speakingSummary = page.locator('main details > summary'); + const summaryMetrics = await speakingSummary.evaluate((element) => { + const icon = element.querySelector('svg'); + if (!icon) throw new Error('Speaking order disclosure is missing its chevron.'); + const summaryBox = element.getBoundingClientRect(); + const iconBox = icon.getBoundingClientRect(); + return Math.abs((summaryBox.top + summaryBox.height / 2) - (iconBox.top + iconBox.height / 2)); + }); + expect(summaryMetrics).toBeLessThanOrEqual(1); + await page.getByText('Speaking order', { exact: true }).click(); await expect(page.getByText('Pro Introductor')).toBeVisible(); await expect(page.getByText('Pro Conclusor')).toBeVisible(); diff --git a/e2e/startup-shell.spec.ts b/e2e/startup-shell.spec.ts new file mode 100644 index 000000000..7846c3b6b --- /dev/null +++ b/e2e/startup-shell.spec.ts @@ -0,0 +1,81 @@ +import { expect, test } from '@playwright/test'; +import { setLocale, stubSupabaseSession } from './helpers/auth'; + +test('shows a dark private startup shell while the entry module is delayed', async ({ page }) => { + await setLocale(page, 'en'); + await page.addInitScript(() => { + const settings = JSON.parse(window.localStorage.getItem('debate-trainer-settings') || '{}'); + settings.state = { ...settings.state, theme: 'dark' }; + window.localStorage.setItem('debate-trainer-settings', JSON.stringify(settings)); + }); + await stubSupabaseSession(page); + await page.route('**/src/bootstrap.ts*', async (route) => { + await new Promise((resolve) => setTimeout(resolve, 1500)); + await route.continue(); + }); + + await page.goto('/app', { waitUntil: 'commit' }); + await expect(page.locator('[data-startup-shell]')).toBeVisible(); + await expect(page.locator('#root > .seo-fallback')).toBeHidden(); + await expect(page.locator('[data-startup-status]')).toHaveText('Loading DebateLab'); + await expect.poll(() => page.evaluate(() => ({ + appShell: document.documentElement.classList.contains('app-shell'), + dark: document.documentElement.classList.contains('dark'), + }))).toEqual({ appShell: true, dark: true }); +}); + +test('shows the startup shell on login while the entry module is delayed', async ({ page }) => { + await setLocale(page, 'en'); + await page.route('**/src/bootstrap.ts*', async (route) => { + await new Promise((resolve) => setTimeout(resolve, 1500)); + await route.continue(); + }); + + await page.goto('/login', { waitUntil: 'commit' }); + await expect(page.locator('[data-startup-shell]')).toBeVisible(); + await expect(page.locator('#root > .seo-fallback')).toBeHidden(); + await expect.poll(() => page.evaluate(() => document.documentElement.classList.contains('app-shell'))).toBe(true); +}); + +test('mounts the app before Jam recorder modules resolve', async ({ page }) => { + await setLocale(page, 'en'); + await stubSupabaseSession(page); + await page.addInitScript(() => { + if (typeof window.MediaRecorder === 'undefined') { + Object.defineProperty(window, 'MediaRecorder', { value: class MediaRecorderStub {} }); + } + }); + await page.route('**/js.jam.dev/*.js', async (route) => { + await new Promise((resolve) => setTimeout(resolve, 6000)); + await route.fulfill({ status: 200, contentType: 'application/javascript', body: '' }); + }); + + await page.goto('/app', { waitUntil: 'domcontentloaded' }); + await expect(page.getByRole('main').getByRole('heading', { name: 'DebateLab', exact: true, level: 1 })).toBeVisible({ timeout: 5_000 }); + await expect(page.locator('#root > .seo-fallback')).toHaveCount(0); + expect(await page.evaluate(() => { + const jam = (window as Window & { __jam__?: { exports?: { metadata?: unknown } } }).__jam__; + return typeof jam?.exports?.metadata === 'function'; + })).toBe(true); +}); + +test('shows a recoverable message when the main entry fails', async ({ page }) => { + await page.route('**/src/main.tsx*', (route) => route.fulfill({ status: 503, body: 'entry unavailable' })); + + await page.goto('/app', { waitUntil: 'domcontentloaded' }); + await expect(page.locator('[data-bootstrap-error]')).toBeVisible(); + await expect(page.getByRole('heading', { name: 'Unable to load DebateLab' })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Reload' })).toBeEnabled(); +}); + +test('keeps the public SEO root visible before a delayed entry module', async ({ page }) => { + await setLocale(page, 'en'); + await page.route('**/src/bootstrap.ts*', async (route) => { + await new Promise((resolve) => setTimeout(resolve, 1500)); + await route.continue(); + }); + + await page.goto('/', { waitUntil: 'commit' }); + await expect(page.locator('#root > .seo-fallback')).toBeVisible(); + await expect(page.locator('#root > .seo-fallback nav a')).toHaveCount(19); +}); diff --git a/index.html b/index.html index 7bf0bd7b5..0d00d37d3 100644 --- a/index.html +++ b/index.html @@ -67,11 +67,98 @@ } +
-
+

DebateLab by Programcode

DebateLab by Programcode is the official DebateLab AI debate prep platform at debate.pi-stack.org. It helps students and competitive debaters practice with AI opponents, generate debate cases, train rebuttals, find evidence leads, build argument flowcharts, and time practice speeches.

+
+
+ DebateLab +

Loading DebateLab

+
+
diff --git a/public/theme-init.js b/public/theme-init.js index 286e0f07e..ea3885722 100644 --- a/public/theme-init.js +++ b/public/theme-init.js @@ -1,5 +1,14 @@ // Prevent flash of wrong theme before React mounts. (function () { + try { + var pathname = window.location.pathname; + if (pathname === '/app' || pathname.indexOf('/app/') === 0 || pathname === '/login') { + document.documentElement.classList.add('app-shell'); + } + } catch { + // Ignore unavailable location or document globals. + } + try { var settings = JSON.parse(localStorage.getItem('debate-trainer-settings') || '{}'); var theme = settings && settings.state && settings.state.theme; diff --git a/src/bootstrap.ts b/src/bootstrap.ts index 916b1f1e6..b06ffc450 100644 --- a/src/bootstrap.ts +++ b/src/bootstrap.ts @@ -1,5 +1,21 @@ import { loadJamRecorder } from './jam-loader'; -void loadJamRecorder() - .catch((): undefined => undefined) - .then(() => import('./main')); +function showBootstrapError() { + const root = document.getElementById('root'); + if (!root) return; + + root.innerHTML = ` +
+
+ DebateLab +

Unable to load DebateLab

+

Reload the page to try again.

+ +
+
+ `; + root.querySelector('button')?.addEventListener('click', () => window.location.reload()); +} + +void import('./main').catch(showBootstrapError); +void loadJamRecorder().catch((): undefined => undefined); diff --git a/src/components/features/factoids/FactoidsForm.tsx b/src/components/features/factoids/FactoidsForm.tsx index c26e0f620..3c8573c9f 100644 --- a/src/components/features/factoids/FactoidsForm.tsx +++ b/src/components/features/factoids/FactoidsForm.tsx @@ -158,7 +158,7 @@ export function FactoidsForm({ onSubmit, isLoading }: FactoidsFormProps) { {/* Timeframe */}
- + , placement: 'bottom', }, + { + target: '[data-tour="mode-crossfire"], [data-tour="nav-crossfire"]', + content: , + placement: 'bottom', + }, + { + target: '[data-tour="mode-sparring"], [data-tour="nav-sparring"]', + content: , + placement: 'bottom', + }, { target: '[data-tour="mode-library"], [data-tour="nav-library"]', content: , @@ -114,8 +124,18 @@ export function OnboardingTour() { return allTourSteps.map((step) => { if (step.target === 'body') return step; const target = String(step.target); - return document.querySelector(target) - ? step + const element = Array.from(document.querySelectorAll(target)) + .find((candidate) => { + const style = window.getComputedStyle(candidate); + const rect = candidate.getBoundingClientRect(); + return style.display !== 'none' + && style.visibility !== 'hidden' + && candidate.getClientRects().length > 0 + && rect.width > 0 + && rect.height > 0; + }); + return element + ? { ...step, target: element } : { ...step, target: 'body', placement: 'center' as const }; }); }, [allTourSteps]); diff --git a/src/components/features/sparring/SparringSetup.tsx b/src/components/features/sparring/SparringSetup.tsx index ef719f0ba..e889083da 100644 --- a/src/components/features/sparring/SparringSetup.tsx +++ b/src/components/features/sparring/SparringSetup.tsx @@ -1,11 +1,12 @@ import { useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { ArrowRight, Check, Clock3, CloudOff, Cpu, Gauge, Radio, Swords } from 'lucide-react'; +import { ArrowRight, Check, ChevronDown, Cpu, Gauge, Swords } from 'lucide-react'; import { Button } from '@/components/ui/button'; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Label } from '@/components/ui/label'; import { Textarea } from '@/components/ui/textarea'; import { Badge } from '@/components/ui/badge'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { ModelSelector } from '@/components/ModelSelector'; import { cn } from '@/lib/utils'; import { @@ -27,10 +28,10 @@ interface SparringSetupProps { isStarting?: boolean; } -const DIFFICULTIES: Array<{ id: SparringDifficulty; name: string; description: string }> = [ - { id: 'easy', name: 'Warm-up', description: 'Clear arguments and generous openings.' }, - { id: 'medium', name: 'Tournament', description: 'A capable opponent who follows the flow.' }, - { id: 'hard', name: 'Ruthless', description: 'Fast counters that look for every gap.' }, +const DIFFICULTIES: Array<{ id: SparringDifficulty; name: string }> = [ + { id: 'easy', name: 'Warm-up' }, + { id: 'medium', name: 'Tournament' }, + { id: 'hard', name: 'Ruthless' }, ]; export function SparringSetup({ onStart, isStarting = false }: SparringSetupProps) { @@ -66,24 +67,9 @@ export function SparringSetup({ onStart, isStarting = false }: SparringSetupProp return (
-
-
-
-
-

{t('sparring.setup.heading', 'Spar with an opponent who listens.')}

-

- {t('sparring.setup.subtitle', 'Choose a motion, take a side, and work through a timed round. The AI keeps the transcript in view, answers the arguments you actually make, and scores the written debate at the end.')} -

-
-
- {isOnline ?
-
+

{t('nav.sparring', 'Sparring')}

-
+
@@ -93,7 +79,6 @@ export function SparringSetup({ onStart, isStarting = false }: SparringSetupProp
{t('sparring.setup.setMotion', 'Set the motion')} - {t('sparring.setup.setMotionDescription', 'Write the resolution as you would hear it in the room.')}
@@ -107,10 +92,9 @@ export function SparringSetup({ onStart, isStarting = false }: SparringSetupProp className="min-h-[132px] resize-y text-base leading-7" maxLength={1200} autoFocus - aria-describedby="sparring-motion-help sparring-motion-count" + aria-describedby="sparring-motion-count" />
- {t('sparring.setup.motionHelp', 'A precise motion gives the opponent something concrete to attack.')} {motion.length}/1,200
{motion.length > 0 && motion.trim().length < 8 && ( @@ -120,11 +104,10 @@ export function SparringSetup({ onStart, isStarting = false }: SparringSetupProp - + {t('sparring.setup.pickSide', 'Pick your side')} - {t('sparring.setup.pickSideDescription', 'You will write every speech for the side you choose.')} - + {(['pro', 'con'] as const).map((side) => { const selected = side === userSide; return ( @@ -134,7 +117,7 @@ export function SparringSetup({ onStart, isStarting = false }: SparringSetupProp aria-pressed={selected} onClick={() => setUserSide(side)} className={cn( - 'motion-press rounded-xl border p-4 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2', + 'motion-press rounded-xl border p-3 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2', selected ? 'border-primary bg-primary/10 text-foreground shadow-sm' : 'border-border bg-background hover:border-primary/40 hover:bg-accent/50', )} > @@ -145,9 +128,6 @@ export function SparringSetup({ onStart, isStarting = false }: SparringSetupProp {selected && - + {t('sparring.setup.choosePressure', 'Choose the pressure')} - {t('sparring.setup.choosePressureDescription', 'The opponent adapts its arguments to this level.')} - + {DIFFICULTIES.map((level) => { const selected = level.id === difficulty; return ( @@ -169,7 +148,7 @@ export function SparringSetup({ onStart, isStarting = false }: SparringSetupProp aria-pressed={selected} onClick={() => setDifficulty(level.id)} className={cn( - 'motion-press rounded-xl border p-4 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2', + 'motion-press rounded-xl border p-3 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2', selected ? 'border-primary bg-primary/10 shadow-sm' : 'border-border hover:border-primary/40 hover:bg-accent/50', )} > @@ -177,7 +156,6 @@ export function SparringSetup({ onStart, isStarting = false }: SparringSetupProp
-