Spaces:
Sleeping
Sleeping
| /** | |
| * ClauseGuard β Multi-format Report Export Utility | |
| * Generates reports in: JSON, CSV, Markdown, Plain Text, HTML | |
| * PDF and DOCX use server-side generation via API routes. | |
| */ | |
| import type { AnalysisResult, Clause, Entity, Contradiction, Obligation, ComplianceReg, Redline } from "./types"; | |
| // ββ Severity ordering ββ | |
| const SEV_ORDER: Record<string, number> = { CRITICAL: 4, HIGH: 3, MEDIUM: 2, LOW: 1 }; | |
| function sevSort(a: string, b: string) { | |
| return (SEV_ORDER[b] || 0) - (SEV_ORDER[a] || 0); | |
| } | |
| function timestamp() { | |
| return new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19); | |
| } | |
| function download(content: string | Blob, filename: string, mime: string) { | |
| const blob = content instanceof Blob ? content : new Blob([content], { type: mime }); | |
| const url = URL.createObjectURL(blob); | |
| const a = document.createElement("a"); | |
| a.href = url; | |
| a.download = filename; | |
| document.body.appendChild(a); | |
| a.click(); | |
| document.body.removeChild(a); | |
| URL.revokeObjectURL(url); | |
| } | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // JSON Export | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| export function exportJSON(results: AnalysisResult, formatted = true) { | |
| const json = formatted | |
| ? JSON.stringify(results, null, 2) | |
| : JSON.stringify(results); | |
| download(json, `clauseguard-report-${timestamp()}.json`, "application/json"); | |
| } | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // CSV Export | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| function escapeCSV(val: string): string { | |
| if (val.includes(",") || val.includes('"') || val.includes("\n")) { | |
| return `"${val.replace(/"/g, '""')}"`; | |
| } | |
| return val; | |
| } | |
| export function exportCSV(results: AnalysisResult) { | |
| const rows: string[] = []; | |
| // Header | |
| rows.push("Section,Category,Severity,Confidence,Source,Text,Description"); | |
| // Clauses | |
| for (const clause of results.results) { | |
| for (const cat of clause.categories) { | |
| rows.push([ | |
| "Clause", | |
| escapeCSV(cat.name), | |
| cat.severity, | |
| cat.confidence != null ? String(Math.round(cat.confidence * 100)) + "%" : "pattern", | |
| cat.confidence != null ? "ML" : "Pattern", | |
| escapeCSV(clause.text.slice(0, 500)), | |
| escapeCSV(cat.description || ""), | |
| ].join(",")); | |
| } | |
| } | |
| // Entities | |
| for (const ent of results.entities) { | |
| rows.push([ | |
| "Entity", | |
| escapeCSV(ent.type), | |
| "", | |
| ent.score ? String(Math.round(ent.score * 100)) + "%" : "", | |
| ent.source || "", | |
| escapeCSV(ent.text), | |
| "", | |
| ].join(",")); | |
| } | |
| // Contradictions | |
| for (const c of results.contradictions) { | |
| rows.push([ | |
| "Contradiction", | |
| escapeCSV(c.type), | |
| c.severity, | |
| c.confidence ? String(Math.round(c.confidence * 100)) + "%" : "", | |
| c.source || "", | |
| escapeCSV(c.explanation), | |
| "", | |
| ].join(",")); | |
| } | |
| // Obligations | |
| for (const o of results.obligations) { | |
| rows.push([ | |
| "Obligation", | |
| escapeCSV(o.type), | |
| o.priority != null && o.priority >= 3 ? "HIGH" : o.priority === 2 ? "MEDIUM" : "LOW", | |
| "", | |
| "", | |
| escapeCSV(o.description), | |
| escapeCSV(`${o.party} Β· ${o.deadline}`), | |
| ].join(",")); | |
| } | |
| download(rows.join("\n"), `clauseguard-report-${timestamp()}.csv`, "text/csv"); | |
| } | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // Markdown Export | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| export function exportMarkdown(results: AnalysisResult) { | |
| const lines: string[] = []; | |
| const flagged = results.results.filter(r => r.categories.length > 0); | |
| const sevCounts = { CRITICAL: 0, HIGH: 0, MEDIUM: 0, LOW: 0 }; | |
| flagged.forEach(r => r.categories.forEach(c => { | |
| if (sevCounts[c.severity as keyof typeof sevCounts] !== undefined) sevCounts[c.severity as keyof typeof sevCounts]++; | |
| })); | |
| lines.push("# π‘οΈ ClauseGuard Analysis Report"); | |
| lines.push(""); | |
| lines.push(`**Generated:** ${new Date().toLocaleString()}`); | |
| lines.push(`**Risk Score:** ${results.risk_score}/100 Β· **Grade:** ${results.grade}`); | |
| lines.push(`**Clauses:** ${results.total_clauses} total Β· ${results.flagged_count} flagged`); | |
| lines.push(`**Model:** ${results.model === "ml" || results.model !== "regex" ? "ML Models" : "Pattern Matching"}`); | |
| lines.push(""); | |
| // Severity breakdown | |
| lines.push("## π Risk Breakdown"); | |
| lines.push(""); | |
| lines.push("| Severity | Count |"); | |
| lines.push("|----------|-------|"); | |
| lines.push(`| π΄ Critical | ${sevCounts.CRITICAL} |`); | |
| lines.push(`| π High | ${sevCounts.HIGH} |`); | |
| lines.push(`| π‘ Medium | ${sevCounts.MEDIUM} |`); | |
| lines.push(`| π’ Low | ${sevCounts.LOW} |`); | |
| lines.push(""); | |
| // Flagged clauses | |
| if (flagged.length > 0) { | |
| lines.push("## β οΈ Flagged Clauses"); | |
| lines.push(""); | |
| for (const clause of flagged) { | |
| const labels = clause.categories.map(c => `**${c.name}** (${c.severity})`).join(", "); | |
| lines.push(`### ${labels}`); | |
| lines.push(""); | |
| lines.push(`> ${clause.text.slice(0, 500)}${clause.text.length > 500 ? "..." : ""}`); | |
| lines.push(""); | |
| for (const cat of clause.categories) { | |
| if (cat.description) lines.push(`- ${cat.description}`); | |
| const src = cat.confidence != null ? `ML ${Math.round(cat.confidence * 100)}%` : "Pattern match"; | |
| lines.push(`- *Source: ${src}*`); | |
| } | |
| lines.push(""); | |
| } | |
| } | |
| // Entities | |
| if (results.entities.length > 0) { | |
| lines.push("## π·οΈ Extracted Entities"); | |
| lines.push(""); | |
| const grouped: Record<string, string[]> = {}; | |
| results.entities.forEach(e => { | |
| if (!grouped[e.type]) grouped[e.type] = []; | |
| if (!grouped[e.type].includes(e.text)) grouped[e.type].push(e.text); | |
| }); | |
| for (const [type, items] of Object.entries(grouped)) { | |
| lines.push(`**${type.replace(/_/g, " ")}:** ${items.join(", ")}`); | |
| } | |
| lines.push(""); | |
| } | |
| // Contradictions | |
| if (results.contradictions.length > 0) { | |
| lines.push("## π Contradictions & Issues"); | |
| lines.push(""); | |
| for (const c of results.contradictions) { | |
| lines.push(`- **[${c.severity}] ${c.type}:** ${c.explanation}`); | |
| } | |
| lines.push(""); | |
| } | |
| // Obligations | |
| if (results.obligations.length > 0) { | |
| lines.push("## π Obligations"); | |
| lines.push(""); | |
| lines.push("| Type | Party | Description | Deadline |"); | |
| lines.push("|------|-------|-------------|----------|"); | |
| for (const o of results.obligations) { | |
| lines.push(`| ${o.type} | ${o.party} | ${o.description.slice(0, 100)} | ${o.deadline} |`); | |
| } | |
| lines.push(""); | |
| } | |
| // Compliance | |
| if (Object.keys(results.compliance).length > 0) { | |
| lines.push("## βοΈ Compliance"); | |
| lines.push(""); | |
| for (const [name, reg] of Object.entries(results.compliance)) { | |
| lines.push(`### ${name} β ${reg.compliance_rate}% (${reg.overall_status})`); | |
| lines.push(`*${reg.description}*`); | |
| lines.push(""); | |
| for (const check of reg.checks) { | |
| const icon = check.status === "PASS" ? "β " : check.status === "MISSING" ? "β" : "β οΈ"; | |
| lines.push(`${icon} ${check.description} (${check.severity})`); | |
| } | |
| lines.push(""); | |
| } | |
| } | |
| // Redlines | |
| if (results.redlines && results.redlines.length > 0) { | |
| lines.push("## βοΈ Redlining Suggestions"); | |
| lines.push(""); | |
| for (const rl of results.redlines) { | |
| lines.push(`### ${rl.clause_label} (${rl.risk_level})`); | |
| lines.push(""); | |
| lines.push(`~~${rl.original_text.slice(0, 200)}~~`); | |
| lines.push(""); | |
| lines.push(`β **Suggested:** ${rl.safe_alternative}`); | |
| lines.push(`π ${rl.legal_basis} Β· π‘οΈ ${rl.consumer_standard}`); | |
| lines.push(""); | |
| } | |
| } | |
| lines.push("---"); | |
| lines.push("*β οΈ Not legal advice. Generated by ClauseGuard AI.*"); | |
| download(lines.join("\n"), `clauseguard-report-${timestamp()}.md`, "text/markdown"); | |
| } | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // Plain Text Export | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| export function exportText(results: AnalysisResult) { | |
| const lines: string[] = []; | |
| const flagged = results.results.filter(r => r.categories.length > 0); | |
| lines.push("βββββββββββββββββββββββββββββββββββββββββββββββββββββββ"); | |
| lines.push(" CLAUSEGUARD ANALYSIS REPORT"); | |
| lines.push("βββββββββββββββββββββββββββββββββββββββββββββββββββββββ"); | |
| lines.push(""); | |
| lines.push(`Date: ${new Date().toLocaleString()}`); | |
| lines.push(`Risk Score: ${results.risk_score}/100`); | |
| lines.push(`Grade: ${results.grade}`); | |
| lines.push(`Clauses: ${results.total_clauses} total, ${results.flagged_count} flagged`); | |
| lines.push(`Entities: ${results.entities.length}`); | |
| lines.push(`Issues: ${results.contradictions.length}`); | |
| lines.push(`Obligations: ${results.obligations.length}`); | |
| lines.push(""); | |
| lines.push("βββββββββββββββββββββββββββββββββββββββββββββββββββββββ"); | |
| lines.push(" FLAGGED CLAUSES"); | |
| lines.push("βββββββββββββββββββββββββββββββββββββββββββββββββββββββ"); | |
| lines.push(""); | |
| for (let i = 0; i < flagged.length; i++) { | |
| const clause = flagged[i]; | |
| const labels = clause.categories.map(c => `[${c.severity}] ${c.name}`).join(", "); | |
| lines.push(`${i + 1}. ${labels}`); | |
| lines.push(` ${clause.text.slice(0, 300)}${clause.text.length > 300 ? "..." : ""}`); | |
| lines.push(""); | |
| } | |
| if (results.entities.length > 0) { | |
| lines.push("βββββββββββββββββββββββββββββββββββββββββββββββββββββββ"); | |
| lines.push(" ENTITIES"); | |
| lines.push("βββββββββββββββββββββββββββββββββββββββββββββββββββββββ"); | |
| lines.push(""); | |
| const grouped: Record<string, string[]> = {}; | |
| results.entities.forEach(e => { | |
| if (!grouped[e.type]) grouped[e.type] = []; | |
| if (!grouped[e.type].includes(e.text)) grouped[e.type].push(e.text); | |
| }); | |
| for (const [type, items] of Object.entries(grouped)) { | |
| lines.push(` ${type}: ${items.join(", ")}`); | |
| } | |
| lines.push(""); | |
| } | |
| if (results.contradictions.length > 0) { | |
| lines.push("βββββββββββββββββββββββββββββββββββββββββββββββββββββββ"); | |
| lines.push(" CONTRADICTIONS & ISSUES"); | |
| lines.push("βββββββββββββββββββββββββββββββββββββββββββββββββββββββ"); | |
| lines.push(""); | |
| for (const c of results.contradictions) { | |
| lines.push(` [${c.severity}] ${c.type}: ${c.explanation}`); | |
| } | |
| lines.push(""); | |
| } | |
| if (results.obligations.length > 0) { | |
| lines.push("βββββββββββββββββββββββββββββββββββββββββββββββββββββββ"); | |
| lines.push(" OBLIGATIONS"); | |
| lines.push("βββββββββββββββββββββββββββββββββββββββββββββββββββββββ"); | |
| lines.push(""); | |
| for (const o of results.obligations) { | |
| lines.push(` [${o.type}] ${o.party}: ${o.description} (${o.deadline})`); | |
| } | |
| lines.push(""); | |
| } | |
| if (results.redlines && results.redlines.length > 0) { | |
| lines.push("βββββββββββββββββββββββββββββββββββββββββββββββββββββββ"); | |
| lines.push(" REDLINING SUGGESTIONS"); | |
| lines.push("βββββββββββββββββββββββββββββββββββββββββββββββββββββββ"); | |
| lines.push(""); | |
| for (const rl of results.redlines) { | |
| lines.push(` [${rl.risk_level}] ${rl.clause_label}`); | |
| lines.push(` ORIGINAL: ${rl.original_text.slice(0, 200)}`); | |
| lines.push(` SUGGESTED: ${rl.safe_alternative}`); | |
| lines.push(""); | |
| } | |
| } | |
| lines.push("βββββββββββββββββββββββββββββββββββββββββββββββββββββββ"); | |
| lines.push(" NOT LEGAL ADVICE β Generated by ClauseGuard AI"); | |
| lines.push("βββββββββββββββββββββββββββββββββββββββββββββββββββββββ"); | |
| download(lines.join("\n"), `clauseguard-report-${timestamp()}.txt`, "text/plain"); | |
| } | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // HTML Export (self-contained styled report) | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| export function exportHTML(results: AnalysisResult) { | |
| const flagged = results.results.filter(r => r.categories.length > 0); | |
| const sevCounts = { CRITICAL: 0, HIGH: 0, MEDIUM: 0, LOW: 0 }; | |
| flagged.forEach(r => r.categories.forEach(c => { | |
| if (sevCounts[c.severity as keyof typeof sevCounts] !== undefined) sevCounts[c.severity as keyof typeof sevCounts]++; | |
| })); | |
| const sevColor: Record<string, string> = { CRITICAL: "#dc2626", HIGH: "#ea580c", MEDIUM: "#ca8a04", LOW: "#16a34a" }; | |
| const clauseHTML = flagged.map(clause => { | |
| const tags = clause.categories.map(c => | |
| `<span style="display:inline-block;background:${sevColor[c.severity] || '#888'}15;color:${sevColor[c.severity] || '#888'};border:1px solid ${sevColor[c.severity] || '#888'}40;padding:2px 10px;border-radius:4px;font-size:12px;font-weight:600;margin-right:4px;">${c.name} (${c.severity})</span>` | |
| ).join(""); | |
| return `<div style="border:1px solid #e5e7eb;border-radius:8px;padding:16px;margin-bottom:12px;"> | |
| <div style="margin-bottom:8px;">${tags}</div> | |
| <p style="font-size:13px;color:#374151;line-height:1.7;margin:0;">${clause.text.replace(/</g, "<").slice(0, 500)}</p> | |
| </div>`; | |
| }).join("\n"); | |
| const entityHTML = (() => { | |
| const grouped: Record<string, string[]> = {}; | |
| results.entities.forEach(e => { | |
| if (!grouped[e.type]) grouped[e.type] = []; | |
| if (!grouped[e.type].includes(e.text)) grouped[e.type].push(e.text); | |
| }); | |
| return Object.entries(grouped).map(([type, items]) => | |
| `<div style="margin-bottom:12px;"><strong style="font-size:12px;text-transform:uppercase;color:#6b7280;">${type.replace(/_/g, " ")}</strong><div style="margin-top:4px;">${items.map(t => `<span style="display:inline-block;background:#f3f4f6;padding:3px 10px;border-radius:4px;font-size:12px;margin:2px;">${t}</span>`).join("")}</div></div>` | |
| ).join("\n"); | |
| })(); | |
| const html = `<!DOCTYPE html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"> | |
| <title>ClauseGuard Report β ${new Date().toLocaleDateString()}</title> | |
| <style> | |
| *{margin:0;padding:0;box-sizing:border-box} | |
| body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;color:#1f2937;background:#fff;padding:40px;max-width:800px;margin:0 auto} | |
| h1{font-size:24px;font-weight:700;margin-bottom:4px} | |
| h2{font-size:16px;font-weight:600;margin:24px 0 12px;padding-bottom:8px;border-bottom:1px solid #e5e7eb} | |
| .meta{font-size:12px;color:#9ca3af} | |
| .score-card{display:flex;justify-content:space-between;align-items:center;background:#fafafa;border:1px solid #e5e7eb;border-radius:12px;padding:20px;margin:16px 0} | |
| .score{font-size:36px;font-weight:700} | |
| .grade{font-size:18px;font-weight:700;padding:6px 16px;border-radius:8px;border:1px solid #e5e7eb} | |
| .sev-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:8px;margin:12px 0} | |
| .sev-item{text-align:center;padding:8px;border-radius:8px} | |
| .disclaimer{margin-top:32px;padding:12px;background:#fefce8;border:1px solid #fde68a;border-radius:8px;font-size:11px;color:#92400e} | |
| @media print{body{padding:20px}h2{break-before:auto}} | |
| </style> | |
| </head> | |
| <body> | |
| <h1>π‘οΈ ClauseGuard Analysis Report</h1> | |
| <p class="meta">${new Date().toLocaleString()} Β· ${results.model !== "regex" ? "ML Models" : "Pattern Matching"}</p> | |
| <div class="score-card"> | |
| <div> | |
| <p class="meta">RISK SCORE</p> | |
| <p class="score">${results.risk_score}<span style="font-size:16px;color:#9ca3af">/100</span></p> | |
| </div> | |
| <span class="grade">Grade ${results.grade}</span> | |
| </div> | |
| <div class="sev-grid"> | |
| <div class="sev-item" style="background:#fef2f2"><strong style="color:#dc2626">${sevCounts.CRITICAL}</strong><br><small style="color:#dc2626">Critical</small></div> | |
| <div class="sev-item" style="background:#fff7ed"><strong style="color:#ea580c">${sevCounts.HIGH}</strong><br><small style="color:#ea580c">High</small></div> | |
| <div class="sev-item" style="background:#fefce8"><strong style="color:#ca8a04">${sevCounts.MEDIUM}</strong><br><small style="color:#ca8a04">Medium</small></div> | |
| <div class="sev-item" style="background:#f0fdf4"><strong style="color:#16a34a">${sevCounts.LOW}</strong><br><small style="color:#16a34a">Low</small></div> | |
| </div> | |
| <p class="meta">${results.total_clauses} clauses Β· ${results.flagged_count} flagged Β· ${results.entities.length} entities Β· ${results.obligations.length} obligations</p> | |
| ${flagged.length > 0 ? `<h2>β οΈ Flagged Clauses (${flagged.length})</h2>${clauseHTML}` : ""} | |
| ${results.entities.length > 0 ? `<h2>π·οΈ Entities (${results.entities.length})</h2>${entityHTML}` : ""} | |
| ${results.contradictions.length > 0 ? `<h2>π Issues (${results.contradictions.length})</h2>${results.contradictions.map(c => `<div style="border:1px solid #e5e7eb;border-left:3px solid ${sevColor[c.severity] || '#888'};border-radius:6px;padding:12px;margin-bottom:8px;"><strong style="color:${sevColor[c.severity]};font-size:11px;text-transform:uppercase">${c.type} (${c.severity})</strong><p style="font-size:13px;margin-top:4px">${c.explanation}</p></div>`).join("")}` : ""} | |
| ${results.obligations.length > 0 ? `<h2>π Obligations (${results.obligations.length})</h2><table style="width:100%;border-collapse:collapse;font-size:12px"><thead><tr style="background:#f9fafb;border-bottom:1px solid #e5e7eb"><th style="text-align:left;padding:8px">Type</th><th style="text-align:left;padding:8px">Party</th><th style="text-align:left;padding:8px">Description</th><th style="text-align:left;padding:8px">Deadline</th></tr></thead><tbody>${results.obligations.map(o => `<tr style="border-bottom:1px solid #f3f4f6"><td style="padding:8px;font-weight:500">${o.type}</td><td style="padding:8px">${o.party}</td><td style="padding:8px">${o.description.slice(0, 120)}</td><td style="padding:8px">${o.deadline}</td></tr>`).join("")}</tbody></table>` : ""} | |
| ${results.redlines && results.redlines.length > 0 ? `<h2>βοΈ Redlining (${results.redlines.length})</h2>${results.redlines.map(rl => `<div style="border:1px solid #e5e7eb;border-radius:8px;padding:16px;margin-bottom:12px"><strong style="color:${sevColor[rl.risk_level]}">${rl.clause_label} (${rl.risk_level})</strong><div style="background:#fef2f2;padding:8px;border-radius:4px;margin:8px 0;font-size:12px;text-decoration:line-through;color:#991b1b">${rl.original_text.slice(0, 200)}</div><div style="background:#f0fdf4;padding:8px;border-radius:4px;font-size:12px;color:#166534">${rl.safe_alternative}</div><p style="font-size:10px;color:#9ca3af;margin-top:6px">π ${rl.legal_basis} Β· π‘οΈ ${rl.consumer_standard}</p></div>`).join("")}` : ""} | |
| <div class="disclaimer">β οΈ <strong>Not legal advice.</strong> This report was generated by ClauseGuard AI for informational purposes only. Consult a licensed attorney for legal decisions.</div> | |
| </body> | |
| </html>`; | |
| download(html, `clauseguard-report-${timestamp()}.html`, "text/html"); | |
| } | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // PDF Export (via server-side API route) | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| export async function exportPDF(results: AnalysisResult) { | |
| try { | |
| const res = await fetch("/api/pdf/report", { | |
| method: "POST", | |
| headers: { "Content-Type": "application/json" }, | |
| body: JSON.stringify(results), | |
| }); | |
| if (!res.ok) throw new Error("PDF generation failed"); | |
| const blob = await res.blob(); | |
| download(blob, `clauseguard-report-${timestamp()}.pdf`, "application/pdf"); | |
| return true; | |
| } catch { | |
| // Fallback: print HTML version | |
| exportHTML(results); | |
| return false; | |
| } | |
| } | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // Export formats manifest (for the UI dropdown) | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| export const EXPORT_FORMATS = [ | |
| { key: "pdf", label: "PDF Report", icon: "π", description: "Formatted PDF document", fn: exportPDF }, | |
| { key: "html", label: "HTML Report", icon: "π", description: "Styled HTML (printable)", fn: exportHTML }, | |
| { key: "md", label: "Markdown", icon: "π", description: "GitHub-flavored markdown", fn: exportMarkdown }, | |
| { key: "txt", label: "Plain Text", icon: "π", description: "Simple text format", fn: exportText }, | |
| { key: "csv", label: "CSV Spreadsheet", icon: "π", description: "For Excel / Google Sheets", fn: exportCSV }, | |
| { key: "json", label: "JSON (formatted)", icon: "π§", description: "Full structured data", fn: (r: AnalysisResult) => exportJSON(r, true) }, | |
| { key: "json-raw", label: "JSON (raw)", icon: "β‘", description: "Compact, no whitespace", fn: (r: AnalysisResult) => exportJSON(r, false) }, | |
| ] as const; | |