diff --git a/.audit/crossfire.tsv b/.audit/crossfire.tsv new file mode 100644 index 000000000..dbe8805f6 --- /dev/null +++ b/.audit/crossfire.tsv @@ -0,0 +1,18 @@ +ts phase decision why evidence result +2026-08-30T00:00:00Z isolation created feature/crossfire-trainer in a separate worktree at fetched origin/main keep the dirty main checkout and Sparring worktrees untouched git worktree list; commit 8952608 isolated branch ready +2026-08-30T00:00:01Z design kept DebateLab Tailwind v3, shadcn, Lucide, and framer-motion Adam explicitly required the existing product system and no new icon or design library package.json; tailwind.config.ts no new design dependencies +2026-08-30T00:00:02Z verification installed the locked dependency tree with npm ci required before current Vitest, build, and Playwright checks package-lock.json; npm ci 797 packages installed; host Node 24 differs from declared Node 22 +2026-08-30T20:45:48Z audit marked the first three timestamps as placeholders and superseded them with this checkpoint the decision log must not present guessed times as exact evidence .audit/crossfire.tsv decisions are accurate; original placeholder times are not timing evidence +2026-08-30T20:49:57Z baseline ran the unchanged full Vitest coverage command before feature edits record the existing selected-scope gate and distinguish pre-existing flakes from Crossfire regressions npm run test:coverage 757 passed; LibraryPage folder-deactivation test timed out at 5 seconds +2026-08-30T21:17:19Z contracts validated Crossfire state, storage, auth cleanup, AI boundaries, source opt-in, and result handoff with focused tests keep illegal states and malformed model output outside the UI src/test/crossfire; src/test/ai/crossfire-client.test.ts; src/test/auth/AuthProvider.behavior.test.tsx 64 focused tests passed +2026-08-30T21:22:01Z backend verified Crossfire prompts and bounded provider body consumption under Deno-compatible tests prompt and response bounds are security requirements, not UI-only checks supabase/functions/ai/prompt-builder.test.ts; supabase/functions/_shared/prompts.test.ts; supabase/functions/_shared/research.test.ts 33 focused tests passed +2026-08-30T21:26:25Z coverage passed the full selected-scope Vitest gate after adding real Crossfire branch tests do not lower thresholds or exclude new maintained logic coverage; vitest.config.ts 74 files and 791 tests passed; 90.33 statements, 83.19 branches, 90.87 functions, 94.17 lines +2026-08-30T21:27:10Z deno checked Edge entrypoints and ran every Deno function test Crossfire changes share the production AI function and need runtime-specific proof supabase/functions 85 tests passed; four Edge entrypoints checked +2026-08-30T21:28:20Z browser passed the complete deterministic browser suite on desktop and mobile prove both Crossfire roles, the debrief, locales, and existing routes without real provider traffic e2e/crossfire.spec.ts; test-results 100 Playwright tests passed; four final-flow screenshots captured +2026-08-30T21:30:10Z formatting kept the repository's existing Deno formatting instead of rewriting seven whole function files deno fmt check proposed thousands of unrelated mechanical changes while ESLint, Deno check, and tests pass deno fmt --check output optional format check failed; no bulk rewrite applied +2026-08-30T21:36:08Z review fixed all five independent review findings before committing account isolation, bounded handoff and error bodies, radio keyboard behavior, and early exit were product requirements commits 42cdfc5 and 7b25c27; src/test/pages/CrossfirePage.test.tsx five findings fixed with regression tests +2026-08-30T21:36:08Z commits created two coherent local feature commits and kept the branch unpushed Adam authorized local commits but prohibited push and deployment commits 42cdfc5 and 7b25c27 core contracts and UI experience committed locally +2026-08-30T21:38:19Z re-review closed the account-switch persistence race and made server-side custom-prompt denial explicit the reviewer found a passive-effect ordering leak and wanted direct-request enforcement to be obvious src/pages/CrossfirePage.tsx; src/test/pages/CrossfirePage.test.tsx; supabase/functions/ai/index.ts stale account state never renders or saves under the next account; focused checks passed +2026-08-31T10:48:33Z PR review fixed all 11 CodeRabbit comments and reran every release gate the review found valid recovery, boundary, localization, and coverage gaps PR #12; 800 Vitest tests; 86 Deno tests; 100 Playwright tests all actionable review findings fixed; current local gates green +2026-08-31T10:55:00Z independent review found that the mounted account-switch path still overwrote the incoming account draft storage isolation required restoring the new owner state before persistence src/pages/CrossfirePage.tsx; src/test/pages/CrossfirePage.test.tsx merge held; owner transition and server regression coverage reopened +2026-08-31T11:01:05Z final remediation fixed the mounted owner transition, kept the 200 KB request cap, and added executable server normalization tests close the independent review findings before pushing a new PR head npm run lint; npm run typecheck; npm run test:coverage; npm run build; deno check; deno test; PORT=4175 npm run test:e2e 76 files and 804 Vitest tests, 88 Deno tests, and 100 Playwright tests passed diff --git a/e2e/authed-pages.spec.ts b/e2e/authed-pages.spec.ts index 5b0fa3ae8..37d6274a5 100644 --- a/e2e/authed-pages.spec.ts +++ b/e2e/authed-pages.spec.ts @@ -6,6 +6,7 @@ const AUTH_ROUTES = [ { path: '/app', name: 'home' }, { path: '/app/generate', name: 'generate' }, { path: '/app/attack', name: 'attack' }, + { path: '/app/crossfire', name: 'crossfire' }, { path: '/app/brainstorm', name: 'brainstorm' }, { path: '/app/factoids', name: 'factoids' }, { path: '/app/ask', name: 'ask' }, diff --git a/e2e/crossfire.spec.ts b/e2e/crossfire.spec.ts new file mode 100644 index 000000000..8faf1fbb3 --- /dev/null +++ b/e2e/crossfire.spec.ts @@ -0,0 +1,132 @@ +import { expect, test, type Page } from '@playwright/test'; +import { setLocale, stubSupabaseSession } from './helpers/auth'; + +type CrossfireRequest = { + feature: string; + input: { + operation: 'next-turn' | 'debrief'; + expectedReply?: 'question' | 'answer'; + config: { mode: 'question-ai' | 'ai-questions-user'; useSourceLibrary: boolean }; + transcript: Array<{ id: string; text: string }>; + }; + sources: unknown[]; +}; + +async function installDeterministicBackend(page: Page, requests: CrossfireRequest[]) { + await page.route('**/*', async (route) => { + const url = new URL(route.request().url()); + if (url.hostname === 'localhost' || url.hostname === '127.0.0.1') { + await route.continue(); + return; + } + + if (url.pathname.includes('/rest/v1/')) { + await route.fulfill({ + status: 200, + contentType: 'application/json', + headers: { 'content-range': '*/0' }, + body: '[]', + }); + return; + } + + if (url.pathname.endsWith('/auth/v1/user')) { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ id: '00000000-0000-0000-0000-000000000001', email: 'e2e@example.com' }), + }); + return; + } + + if (url.pathname.endsWith('/functions/v1/ai')) { + const body = route.request().postDataJSON() as CrossfireRequest; + requests.push(body); + if (body.input.operation === 'next-turn') { + const questionCount = requests.filter((request) => + request.input.operation === 'next-turn' && request.input.expectedReply === 'question' + ).length; + const output = body.input.expectedReply === 'answer' + ? { type: 'answer', text: 'The case relies on reduced traffic exposure and documented collision risk.' } + : { + type: 'question', + text: questionCount === 1 + ? 'What evidence connects fewer private cars to safer streets?' + : 'How would you answer the strongest enforcement objection?', + }; + await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ output }) }); + return; + } + + const output = { + concessionsSecured: [{ turnIds: ['T2'], claim: 'The answer accepts that the safety link depends on traffic exposure.' }], + controlledQuestions: [{ turnIds: ['T1', 'T2'], claim: 'The opening exchange keeps the discussion on the central causal warrant.' }], + weakOrRepetitiveQuestions: [{ turnIds: ['T1'], claim: 'The question could identify the type of evidence required more precisely.' }], + evasiveOrIncompleteAnswers: [{ turnIds: ['T2'], claim: 'The answer does not name a study or traffic count.' }], + missedFollowUps: [{ turnIds: ['T2'], claim: 'The next question should test the missing evidence.' }], + betterFollowUpQuestions: [{ turnIds: ['T2'], question: 'Which study measures that reduction in collision risk?', reason: 'It turns the general answer into a checkable claim.' }], + strongestExchange: { turnIds: ['T1', 'T2'], claim: 'The first exchange isolates the case\'s main empirical dependency.' }, + }; + await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ output }) }); + return; + } + + await route.abort('blockedbyclient'); + }); +} + +async function prepare(page: Page, requests: CrossfireRequest[]) { + await setLocale(page, 'en'); + await stubSupabaseSession(page); + await page.addInitScript(() => { + for (const key of Object.keys(window.localStorage)) { + if (key.startsWith('debatelab-crossfire-draft:')) window.localStorage.removeItem(key); + } + }); + await installDeterministicBackend(page, requests); + await page.goto('/app/crossfire'); + await page.getByTestId('crossfire-resolution').fill('Resolved: cities should ban private cars downtown.'); + await page.getByTestId('crossfire-argument').fill('Private cars create congestion and unsafe streets.'); +} + +test('Question the AI completes a grounded Crossfire debrief', async ({ page }, testInfo) => { + const requests: CrossfireRequest[] = []; + await prepare(page, requests); + await page.getByTestId('mode-question-ai').click(); + await page.getByTestId('style-cooperative').click(); + await page.getByTestId('source-grounding').click(); + await page.getByTestId('start-crossfire').click(); + + await page.getByTestId('crossfire-input').fill('What evidence supports the safety mechanism?'); + await page.getByTestId('submit-turn').click(); + await expect(page.getByText('The case relies on reduced traffic exposure and documented collision risk.')).toBeVisible(); + await page.getByTestId('end-crossfire').click(); + + await expect(page.getByTestId('crossfire-debrief')).toBeVisible(); + await expect(page.getByText('T1', { exact: true }).first()).toBeVisible(); + await expect(page.getByText('T2', { exact: true }).first()).toBeVisible(); + expect(requests[0].input).toMatchObject({ operation: 'next-turn', expectedReply: 'answer', config: { mode: 'question-ai', useSourceLibrary: true } }); + expect(requests[0].input.transcript.at(-1)?.text).toBe('What evidence supports the safety mechanism?'); + expect(requests.at(-1)?.input.operation).toBe('debrief'); + await page.screenshot({ path: testInfo.outputPath('crossfire-question-ai-final.png'), fullPage: true }); +}); + +test('Let the AI question me alternates roles and completes a debrief', async ({ page }, testInfo) => { + const requests: CrossfireRequest[] = []; + await prepare(page, requests); + await page.getByTestId('mode-ai-questions-user').click(); + await page.getByTestId('style-aggressive').click(); + await page.getByTestId('start-crossfire').click(); + + await expect(page.getByText('What evidence connects fewer private cars to safer streets?')).toBeVisible(); + await page.getByTestId('crossfire-input').fill('Traffic exposure falls when trips shift to transit and walking.'); + await page.getByTestId('submit-turn').click(); + await expect(page.getByText('How would you answer the strongest enforcement objection?')).toBeVisible(); + await page.getByTestId('end-crossfire').click(); + + await expect(page.getByTestId('crossfire-debrief')).toBeVisible(); + expect(requests[0].input).toMatchObject({ operation: 'next-turn', expectedReply: 'question', config: { mode: 'ai-questions-user', useSourceLibrary: false } }); + expect(requests[1].input.transcript.at(-1)?.text).toBe('Traffic exposure falls when trips shift to transit and walking.'); + expect(requests.at(-1)?.input.operation).toBe('debrief'); + await page.screenshot({ path: testInfo.outputPath('crossfire-ai-questions-user-final.png'), fullPage: true }); +}); diff --git a/playwright.config.ts b/playwright.config.ts index 97c7daaa2..ec97f7798 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -27,7 +27,7 @@ export default defineConfig({ }, ], webServer: { - command: 'npm run dev', + command: `npm run dev -- --port ${PORT}`, url: baseURL, reuseExistingServer: !process.env.CI, timeout: 120_000, diff --git a/src/App.tsx b/src/App.tsx index f68db8b2e..8324d6a1a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -25,6 +25,7 @@ const AttackPage = lazy(() => import("./pages/AttackPage")); const BrainstormPage = lazy(() => import("./pages/BrainstormPage")); const FactoidsPage = lazy(() => import("./pages/FactoidsPage")); const AskPage = lazy(() => import("./pages/AskPage")); +const CrossfirePage = lazy(() => import("./pages/CrossfirePage")); const SettingsPage = lazy(() => import("./pages/SettingsPage")); const HistoryPage = lazy(() => import("./pages/HistoryPage")); const LibraryPage = lazy(() => import("./pages/LibraryPage")); @@ -118,6 +119,7 @@ function AnimatedRoutes() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/src/components/features/attack/AttackResults.tsx b/src/components/features/attack/AttackResults.tsx index 17809d34b..8fe8dfeeb 100644 --- a/src/components/features/attack/AttackResults.tsx +++ b/src/components/features/attack/AttackResults.tsx @@ -1,7 +1,7 @@ // src/components/features/attack/AttackResults.tsx import { useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { AlertCircle, AlertTriangle, Info, Scale, Lightbulb, MessageSquare } from 'lucide-react'; +import { AlertCircle, AlertTriangle, Info, Scale, Lightbulb, MessageSquare, Flame } from 'lucide-react'; import { Badge } from '@/components/ui/badge'; import { SectionCard } from '@/components/ui/section-card'; import { Progress } from '@/components/ui/progress'; @@ -18,12 +18,14 @@ import { GoogleDriveAuthModal } from '@/components/features/export/GoogleDriveAu import { SourceQualityList } from '@/components/features/shared/SourceQualityList'; import type { DiscoveredSource } from '@/lib/ai/client'; import { openExternalUrl } from '@/lib/native'; +import { Button } from '@/components/ui/button'; interface AttackResultsProps { result: AttackOutput; discoveredSources?: DiscoveredSource[]; onDiscuss?: () => void; + onPracticeCrossfire?: () => void; } const severityConfig = { @@ -83,7 +85,7 @@ function WeaknessCard({ weakness }: { weakness: Weakness }) { ); } -export function AttackResults({ result, discoveredSources, onDiscuss }: AttackResultsProps) { +export function AttackResults({ result, discoveredSources, onDiscuss, onPracticeCrossfire }: AttackResultsProps) { const { t } = useTranslation(); const [isExportingGDocs, setIsExportingGDocs] = useState(false); const [showDriveAuth, setShowDriveAuth] = useState(false); @@ -195,6 +197,12 @@ export function AttackResults({ result, discoveredSources, onDiscuss }: AttackRe isExportingGDocs={isExportingGDocs} onDiscuss={onDiscuss} discussLabel={t('attackResults.discuss')} + secondaryActions={onPracticeCrossfire ? ( + + ) : undefined} /> diff --git a/src/components/features/generate/GenerateResults.tsx b/src/components/features/generate/GenerateResults.tsx index 9f1f94567..9e34bbb6c 100644 --- a/src/components/features/generate/GenerateResults.tsx +++ b/src/components/features/generate/GenerateResults.tsx @@ -1,6 +1,6 @@ import { useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { Target, Shield, Swords, AlertTriangle, MessageSquare, Save, GitBranch, Sparkles, Clipboard, Undo2 } from 'lucide-react'; +import { Target, Shield, Swords, AlertTriangle, MessageSquare, Save, GitBranch, Sparkles, Clipboard, Undo2, Flame } from 'lucide-react'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { SectionCard } from '@/components/ui/section-card'; @@ -26,6 +26,7 @@ interface GenerateResultsProps { onSave?: () => void; onUndo?: () => void; onImportFlowchart?: () => void; + onPracticeCrossfire?: () => void; } function ArgumentCard({ contention, index }: { contention: Contention; index: number }) { @@ -76,7 +77,7 @@ function formatDuration(ms: number | undefined, t: (key: string, options?: Recor return t('generateResults.durationS', { value }); } -export function GenerateResults({ result, discoveredSources, onRegenerateSection, onDiscuss, onSave, onUndo, onImportFlowchart }: GenerateResultsProps) { +export function GenerateResults({ result, discoveredSources, onRegenerateSection, onDiscuss, onSave, onUndo, onImportFlowchart, onPracticeCrossfire }: GenerateResultsProps) { const { t } = useTranslation(); const [isExportingGDocs, setIsExportingGDocs] = useState(false); const [showDriveAuth, setShowDriveAuth] = useState(false); @@ -188,8 +189,14 @@ export function GenerateResults({ result, discoveredSources, onRegenerateSection {t('generateResults.clipboard', 'Clipboard')} )} - secondaryActions={(onSave || onDiscuss || onImportFlowchart || onUndo) ? ( + secondaryActions={(onSave || onDiscuss || onImportFlowchart || onUndo || onPracticeCrossfire) ? ( <> + {onPracticeCrossfire && ( + + )} {onSave && ( + + +
+ + + + + +
+

{t('crossfire.betterFollowUpQuestions')}

+ {debrief.betterFollowUpQuestions.length === 0 ? ( +

{empty}

+ ) : ( +
    + {debrief.betterFollowUpQuestions.map((item, index) => ( +
  • +

    {item.question}

    +

    {item.reason}

    + +
  • + ))} +
+ )} +
+
+ +
+
+ +

{t('crossfire.strongestExchange')}

+
+ {debrief.strongestExchange ? ( +
+

{debrief.strongestExchange.claim}

+ +
+ ) : ( +

{empty}

+ )} +
+ + ); +} + +export default function CrossfirePage() { + const { t } = useTranslation(); + const { user } = useAuth(); + const location = useLocation(); + const importedPrefill = useMemo(() => routePrefill(location.state), [location.state]); + const [state, dispatch] = useReducer( + crossfireReducer, + undefined, + () => restoreState(user?.id, importedPrefill), + ); + const [draftTurn, setDraftTurn] = useState(''); + const [now, setNow] = useState(Date.now()); + const [formError, setFormError] = useState(''); + const [storageError, setStorageError] = useState(false); + const [stateOwnerId, setStateOwnerId] = useState(user?.id); + const requestRef = useRef(null); + const shouldReduceMotion = useReducedMotion(); + const activeSourceCount = useSessionStore((session) => session.sourceLibrary.filter((source) => source.isActive).length); + const { getFeatureConfig } = useSettingsStore(); + const modelConfig = getFeatureConfig('ask'); + + useEffect(() => () => requestRef.current?.abort(), []); + + useEffect(() => { + if (stateOwnerId === user?.id) return; + requestRef.current?.abort(); + dispatch({ type: 'state-restored', state: restoreState(user?.id, undefined) }); + setStateOwnerId(user?.id); + setDraftTurn(''); + setFormError(''); + }, [stateOwnerId, user?.id]); + + useEffect(() => { + if (!user?.id || stateOwnerId !== user.id) return; + const saved = saveCrossfireDraft(user.id, state); + setStorageError(!saved && state.phase !== 'complete'); + }, [state, stateOwnerId, user?.id]); + + useEffect(() => { + if (state.phase !== 'active') return; + setNow(Date.now()); + const interval = window.setInterval(() => setNow(Date.now()), 500); + return () => window.clearInterval(interval); + }, [state.phase]); + + const runNextTurn = useCallback(async ( + snapshot: Extract, + expectedReply: 'question' | 'answer', + ) => { + requestRef.current?.abort(); + const controller = new AbortController(); + requestRef.current = controller; + try { + const result = await crossfire({ + operation: 'next-turn', + config: snapshot.config, + transcript: snapshot.turns, + expectedReply, + }, modelConfig, { signal: controller.signal }); + if (controller.signal.aborted) return; + if (!('type' in result)) { + dispatch({ + type: 'request-failed', + expectedReply, + errorKind: 'response', + message: 'crossfire.invalidResponseDesc', + }); + return; + } + dispatch({ + type: 'ai-turn-received', + turn: nextTurn(snapshot.turns.length + 1, 'ai', result.type, result.text), + }); + } catch (error) { + if (controller.signal.aborted) return; + const classified = classifyError(error); + dispatch({ type: 'request-failed', expectedReply, errorKind: classified.kind, message: classified.message }); + } + }, [modelConfig]); + + const runDebrief = useCallback(async (snapshot: Extract) => { + requestRef.current?.abort(); + const controller = new AbortController(); + requestRef.current = controller; + try { + const result = await crossfire({ + operation: 'debrief', + config: snapshot.config, + transcript: snapshot.turns, + }, modelConfig, { signal: controller.signal }); + if (controller.signal.aborted) return; + if ('type' in result) { + dispatch({ + type: 'debrief-failed', + errorKind: 'response', + message: 'crossfire.invalidDebriefResponseDesc', + }); + return; + } + dispatch({ type: 'debrief-received', debrief: result }); + } catch (error) { + if (controller.signal.aborted) return; + const classified = classifyError(error); + dispatch({ type: 'debrief-failed', errorKind: classified.kind, message: classified.message }); + } + }, [modelConfig]); + + const endSession = useCallback((reason: 'manual' | 'timer') => { + if (state.phase !== 'active') return; + requestRef.current?.abort(); + if (state.turns.length === 0) { + dispatch({ + type: 'session-reset', + prefill: { + resolution: state.config.resolution, + argument: state.config.argument, + seedQuestions: state.config.seedQuestions, + }, + }); + setFormError(t('crossfire.debriefEmptyDesc')); + return; + } + const next = crossfireReducer(state, { type: 'session-ended', now: Date.now(), reason }); + dispatch({ type: 'session-ended', now: next.phase === 'debriefing' ? next.endedAt : Date.now(), reason }); + if (next.phase === 'debriefing') void runDebrief(next); + }, [runDebrief, state, t]); + + useEffect(() => { + if (state.phase === 'active' && now >= state.endsAt) endSession('timer'); + }, [endSession, now, state]); + + const startSession = () => { + if (state.phase !== 'setup') return; + const parsed = CrossfireConfigSchema.safeParse(state.config); + if (!parsed.success) { + setFormError(t('crossfire.validationError', 'Enter a resolution and argument before starting.')); + return; + } + setFormError(''); + const action = { + type: 'session-started' as const, + sessionId: `${SESSION_ID_PREFIX}-${Date.now()}`, + now: Date.now(), + }; + const next = crossfireReducer(state, action); + dispatch(action); + if (next.phase === 'active' && next.request.status === 'pending') { + void runNextTurn(next, next.request.expectedReply); + } + }; + + const submitTurn = () => { + if (state.phase !== 'active') return; + const kind = expectedUserTurnKind(state); + const text = draftTurn.trim(); + if (!kind || !text || text.length > MAX_CROSSFIRE_TURN_CHARS) return; + const expectedReply: 'question' | 'answer' = kind === 'question' ? 'answer' : 'question'; + const action = { + type: 'user-turn-submitted' as const, + turn: nextTurn(state.turns.length + 1, 'user', kind, text), + expectedReply, + }; + const next = crossfireReducer(state, action); + dispatch(action); + setDraftTurn(''); + if (next.phase === 'active') void runNextTurn(next, expectedReply); + }; + + const retryTurn = () => { + if (state.phase !== 'active' || state.request.status !== 'failed') return; + const expectedReply = state.request.expectedReply; + const next = crossfireReducer(state, { type: 'request-retried' }); + dispatch({ type: 'request-retried' }); + if (next.phase === 'active') void runNextTurn(next, expectedReply); + }; + + const retryDebrief = () => { + if (state.phase !== 'debriefing' || state.requestStatus !== 'failed') return; + const next = crossfireReducer(state, { type: 'debrief-retried' }); + dispatch({ type: 'debrief-retried' }); + if (next.phase === 'debriefing') void runDebrief(next); + }; + + const restart = () => { + requestRef.current?.abort(); + if (user?.id) clearCrossfireDraft(user.id); + dispatch({ type: 'session-reset' }); + setDraftTurn(''); + setFormError(''); + }; + + if (stateOwnerId !== user?.id) { + return ( +
+ + {t('crossfire.loading')} +
+ ); + } + + if (state.phase === 'setup') { + return ( +
+
+
+
+ +
+

{t('crossfire.title')}

+

{t('crossfire.subtitle')}

+
+ + {(formError || storageError) && ( + + + {t('crossfire.error')} + {formError || t('crossfire.storageError', 'This browser could not save the unfinished session locally.')} + + )} + + {importedPrefill?.source && ( + + + {importedPrefill.source === 'generate' ? t('crossfire.importFromGenerate') : t('crossfire.importFromAttack')} + + {importedPrefill.source === 'generate' ? t('crossfire.importGenerateDesc') : t('crossfire.importAttackDesc')} + + + )} + + + + {t('crossfire.setupTitle')} + {t('crossfire.setupDescription')} + + +
+ +