import { useEffect, useState } from 'react'; import { createPortal } from 'react-dom'; import { useAI } from '../hooks/useAI'; import { markdownToHtml } from '../lib/markdown'; import { issueTitle } from '../lib/ruleTitles'; import type { Issue, PageScanSummary } from '../lib/types'; import { labelsForRulesets } from '../lib/rulesets'; function scoreColor(s: number) { return s >= 90 ? '#22c55e' : s >= 75 ? '#84cc16' : s >= 55 ? '#f59e0b' : s >= 35 ? '#f97316' : '#ef4444'; } interface Props { scan: PageScanSummary | null; onClose: () => void; } export default function ScanSlideIn({ scan, onClose }: Props) { const { run, hasProvider } = useAI(); const [filter, setFilter] = useState<'all' | 'critical' | 'serious' | 'warning' | 'notice' | 'passed'>('all'); const [detailIssue, setDetailIssue] = useState(null); const [visible, setVisible] = useState(false); const [aiOutput, setAiOutput] = useState(null); const [aiError, setAiError] = useState(null); const [activeAction, setActiveAction] = useState(null); // Which task produced the current aiOutput ('fix' vs 'explain') — activeAction // itself gets reset to null once the call finishes, so it can't be used to pick // the right result label; this survives for as long as aiOutput is shown. const [resultTask, setResultTask] = useState(null); const [openNodeIdx, setOpenNodeIdx] = useState(0); const [openPassIdx, setOpenPassIdx] = useState(-1); const [nodeTip, setNodeTip] = useState<{ text: string; x: number; y: number } | null>(null); // Animate in when scan arrives; reset all state when scan changes useEffect(() => { if (scan) { setFilter('all'); setDetailIssue(null); setOpenPassIdx(-1); setAiOutput(null); setAiError(null); setActiveAction(null); setResultTask(null); requestAnimationFrame(() => setVisible(true)); } else { setVisible(false); } }, [scan]); // Reset AI + accordion when switching issues useEffect(() => { setAiOutput(null); setAiError(null); setActiveAction(null); setResultTask(null); setOpenNodeIdx(0); }, [detailIssue?.id]); // Escape key: close detail → then close panel useEffect(() => { if (!scan) return; const handler = (e: KeyboardEvent) => { if (e.key !== 'Escape') return; if (detailIssue) setDetailIssue(null); else onClose(); }; document.addEventListener('keydown', handler); return () => document.removeEventListener('keydown', handler); }, [scan, onClose, detailIssue]); const callAI = async (task: string) => { if (!detailIssue) return; setAiOutput(null); setAiError(null); setActiveAction(task); try { const result = await run.mutateAsync({ task, params: { title: detailIssue.title, wcag: detailIssue.wcag ?? '', html: detailIssue.element ?? '', context: detailIssue.description, }, }); if (result.error) setAiError(result.error); else { setAiOutput(result.text ?? ''); setResultTask(task); } } catch (e) { setAiError((e as Error).message); } finally { setActiveAction(null); } }; if (!scan) return null; const scanRulesets = scan.rulesets ?? (scan.ruleset ? [scan.ruleset] : []); const allIssues = scan.issues ?? []; const issues = allIssues.filter((i) => i.category !== 'best-practice'); const bestPractices = allIssues.filter((i) => i.category === 'best-practice'); // Severity filter tabs only show WCAG issues — best-practice items stay in the "All" tab. const filtered = filter === 'all' ? issues : issues.filter((i) => i.severity === filter); const filteredBP = filter === 'all' ? bestPractices : []; const critical = issues.filter((i) => i.severity === 'critical').length; const serious = issues.filter((i) => i.severity === 'serious').length; const warning = issues.filter((i) => i.severity === 'warning').length; const notice = issues.filter((i) => i.severity === 'notice').length; // "Issues found" everywhere in the app (Dashboard, Report, Pages & Posts) is the // full total — matches the "All" tab below (WCAG + best-practice, all severities). const total = allIssues.length; const color = scoreColor(scan.score); const showDetail = detailIssue !== null; const sevColor = detailIssue ? detailIssue.severity === 'critical' ? '#ef4444' : detailIssue.severity === 'serious' ? '#f97316' : detailIssue.severity === 'warning' ? '#f59e0b' : '#3b82f6' : '#3b82f6'; return ( <> {/* Backdrop */}