diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts index 418f203..46b390e 100644 --- a/frontend/playwright.config.ts +++ b/frontend/playwright.config.ts @@ -1,6 +1,6 @@ import { defineConfig, devices } from '@playwright/test' -const baseURL = process.env.BASE_URL || 'http://localhost:3000' +const baseURL = process.env.BASE_URL || 'http://localhost:3001' const isRemote = baseURL.startsWith('https://') export default defineConfig({ @@ -8,13 +8,14 @@ export default defineConfig({ globalTeardown: './tests/global-teardown.ts', fullyParallel: true, forbidOnly: !!process.env.CI, - retries: process.env.CI ? 2 : 0, + retries: process.env.CI ? 2 : 1, workers: process.env.CI ? 1 : undefined, - reporter: 'html', + reporter: [['html'], ['list']], use: { baseURL, trace: 'on-first-retry', screenshot: 'only-on-failure', + video: 'off', }, projects: [ @@ -23,19 +24,25 @@ export default defineConfig({ use: { ...devices['Desktop Chrome'] }, }, { - name: 'firefox', - use: { ...devices['Desktop Firefox'] }, - }, - { - name: 'webkit', - use: { ...devices['Desktop Safari'] }, + name: 'mobile-chrome', + use: { ...devices['Pixel 5'] }, }, + ...(process.env.CI ? [ + { + name: 'firefox', + use: { ...devices['Desktop Firefox'] }, + }, + { + name: 'webkit', + use: { ...devices['Desktop Safari'] }, + }, + ] : []), ], ...(isRemote ? {} : { webServer: { command: 'npm run dev', - url: 'http://localhost:3000', + url: 'http://localhost:3001', reuseExistingServer: !process.env.CI, timeout: 120 * 1000, }, diff --git a/frontend/src/app/dashboard/page.tsx b/frontend/src/app/dashboard/page.tsx index aed1675..4fa494b 100644 --- a/frontend/src/app/dashboard/page.tsx +++ b/frontend/src/app/dashboard/page.tsx @@ -5,10 +5,9 @@ import { motion, AnimatePresence } from 'framer-motion' import { useRouter } from 'next/navigation' import { FileText, Search, Upload, AlertTriangle, CheckCircle, - Clock, Database, Zap, ChevronRight, X, Loader2, - FileWarning, Shield, Network, TrendingUp, BarChart3, - Eye, PlayCircle, Pencil, Check, Activity, - MessageCircle, FileBarChart, Trash2, RotateCcw + Clock, ChevronRight, X, Loader2, + FileWarning, Shield, Network, BarChart3, + Eye, PlayCircle, Pencil, Check } from 'lucide-react' import { api, Document, AnalysisSummary } from '@/lib/api' import { useToast } from '@/lib/toast' @@ -23,11 +22,11 @@ const riskColors: Record = { low: 'text-emerald-400 bg-emerald-500/10 border-emerald-500/20', } -const riskGlow: Record = { - critical: 'shadow-[0_0_20px_rgba(239,68,68,0.3)]', - high: 'shadow-[0_0_15px_rgba(249,115,22,0.2)]', - medium: 'shadow-[0_0_10px_rgba(245,158,11,0.15)]', - low: 'shadow-none', +const riskAccentBorder: Record = { + critical: 'border-l-red-500', + high: 'border-l-orange-500', + medium: 'border-l-amber-500', + low: 'border-l-emerald-500', } function formatRelativeTime(iso: string): string { @@ -74,14 +73,6 @@ function DashboardContent() { const [renamingDoc, setRenamingDoc] = useState(null) const [renameValue, setRenameValue] = useState('') const [dragOver, setDragOver] = useState(false) - const [activities, setActivities] = useState - created_at: string - filename: string | null - }>>([]) const fileInputRef = useRef(null) const renameInputRef = useRef(null) const pollRef = useRef | null>(null) @@ -89,14 +80,12 @@ function DashboardContent() { const loadData = useCallback(async () => { try { - const [docsResponse, statsResponse, activityResponse] = await Promise.all([ + const [docsResponse, statsResponse] = await Promise.all([ api.documents.list({ limit: 50 }), api.search.stats(), - api.activity.list(15).catch(() => ({ activities: [], total: 0 })), ]) setDocuments(docsResponse.documents) setStats(statsResponse) - setActivities(activityResponse.activities) // Load analyses for all completed documents in parallel const completed = docsResponse.documents.filter(d => d.status === 'ready' || d.status === 'analyzed') @@ -186,7 +175,7 @@ function DashboardContent() { } const handleSearch = async () => { - if (!searchQuery.trim()) return + if (!searchQuery.trim() || searching) return setSearching(true) try { const response = await api.search.query(searchQuery, { limit: 10 }) @@ -436,76 +425,45 @@ function DashboardContent() {

Contract Dashboard

- {/* Stats Row - Data Dense */} - {loading ? ( - Array.from({ length: 4 }).map((_, i) => ( -
-
-
-
-
-
-
-
-
-
+ Array.from({ length: 3 }).map((_, i) => ( +
+
+
)) ) : ( <> - } + router.push('/search')} - /> - } - value={stats?.chunks_with_embeddings ?? 0} - label="Text Chunks" - sublabel="Vector embeddings" - delay={0.05} + label="Contracts" onClick={() => router.push('/search')} /> - } + { const docWithClauses = documents.find(d => d.status === 'completed') - if (docWithClauses) { - router.push(`/documents/${docWithClauses.id}`) - } else { - router.push('/search') - } + if (docWithClauses) router.push(`/documents/${docWithClauses.id}`) + else router.push('/search') }} /> - } + d.status === 'completed').length} - label="Ready for Review" - sublabel="Completed processing" - delay={0.15} + label="Ready to review" onClick={() => { const completed = documents.find(d => d.status === 'completed') - if (completed) { - loadAnalysis(completed.id) - } + if (completed) loadAnalysis(completed.id) }} /> )} - +
{/* Search Results */} @@ -520,7 +478,7 @@ function DashboardContent() {

Search Results

-

{searchResults.length} matches found

+

{searchResults.length} matches found

+ {documents.length > 0 && ( +
+

+ {documents.length} {documents.length === 1 ? 'contract' : 'contracts'} · click to see risk +

-
-
+ )} +
{loading ? (
{Array.from({ length: 6 }).map((_, i) => ( @@ -612,9 +559,9 @@ function DashboardContent() {
-

Upload your first contract

+

Add your first contract

- Drop a PDF here or click below. BrightClause will extract clauses, score risks, and build a searchable index automatically. + Drop any PDF here. We read every clause, flag risky provisions, and surface what needs your attention - in minutes.

)}
- 0 && ( <> · - - {doc.chunk_count} chunks + + {doc.chunk_count} sections )} {doc.status === 'completed' && ( <> · - + Ready @@ -767,7 +734,7 @@ function DashboardContent() { e.preventDefault() navigateToDocument(doc.id) }} - className="p-2 bg-accent/10 text-accent hover:bg-accent/20 rounded-lg transition-colors" + className="p-3 sm:p-2 min-w-[44px] sm:min-w-0 min-h-[44px] sm:min-h-0 flex items-center justify-center bg-accent/10 text-accent hover:bg-accent/20 rounded-lg transition-colors" aria-label="View document details" title="View Details" > @@ -798,14 +765,16 @@ function DashboardContent() { {/* Header */}

Risk Assessment

-

- {selectedAnalysis ? 'Document Analysis' : `Portfolio · ${portfolioRisk?.docCount} Documents`} +

+ {selectedAnalysis ? 'Document analysis' : `Portfolio · ${portfolioRisk?.docCount} contracts`}

{(() => { const risk = selectedAnalysis ?? portfolioRisk! - const overallRisk = selectedAnalysis ? selectedAnalysis.overall_risk as RiskLevel : portfolioRisk!.overall + const validRiskLevels: RiskLevel[] = ['critical', 'high', 'medium', 'low'] + const rawRisk = selectedAnalysis ? selectedAnalysis.overall_risk : portfolioRisk!.overall + const overallRisk: RiskLevel = validRiskLevels.includes(rawRisk as RiskLevel) ? rawRisk as RiskLevel : 'low' const clauses = selectedAnalysis ? selectedAnalysis.clauses_extracted : portfolioRisk!.totalClauses const riskCounts = selectedAnalysis ? { critical: selectedAnalysis.risk_summary.critical || 0, high: selectedAnalysis.risk_summary.high || 0, medium: selectedAnalysis.risk_summary.medium || 0, low: selectedAnalysis.risk_summary.low || 0 } @@ -813,12 +782,14 @@ function DashboardContent() { const highlights = selectedAnalysis ? selectedAnalysis.high_risk_highlights : portfolioRisk!.highlights return (
- {/* Overall Risk - Prominent */} -
-
-
- Overall Risk Level -
+ {/* Overall Risk - Left-border treatment */} + +
{overallRisk}
-
-
- {clauses} Clauses Analyzed -
-
+
{clauses} clauses analyzed
-
+ - {/* Risk Distribution - Data Dense Grid */} + {/* Risk Distribution - data row + stacked bar */}
-

- Risk Distribution -

-
- {(['critical', 'high', 'medium', 'low'] as RiskLevel[]).map((level) => ( -
Risk breakdown

+
+ {([ + { level: 'critical' as RiskLevel, color: 'text-red-400' }, + { level: 'high' as RiskLevel, color: 'text-orange-400' }, + { level: 'medium' as RiskLevel, color: 'text-amber-400' }, + { level: 'low' as RiskLevel, color: 'text-emerald-400' }, + ]).map(({ level, color }, idx) => ( + -
- {riskCounts[level]} -
-
- {level} -
-
+
{riskCounts[level]}
+
{level}
+ ))}
+ {clauses > 0 && ( +
+ {([ + { count: riskCounts.critical, color: 'bg-red-500' }, + { count: riskCounts.high, color: 'bg-orange-400' }, + { count: riskCounts.medium, color: 'bg-amber-400' }, + { count: riskCounts.low, color: 'bg-emerald-500' }, + ] as { count: number; color: string }[]).filter(s => s.count > 0).map((s, i) => ( + + ))} +
+ )}
{/* High Risk Highlights */} {highlights.length > 0 && (
-

- Attention Required -

+

Attention required

{highlights.slice(0, 3).map((highlight, i) => ( - {highlight.clause_type.replace(/_/g, ' ')} + {(highlight.clause_type ?? '').replace(/_/g, ' ')}

@@ -946,16 +928,16 @@ function DashboardContent() { >

Risk Assessment

-

Not Yet Analyzed

+

Not yet analyzed

-
-
- +
+
+
-

No analysis yet

+

Analyze this contract

- Run AI analysis to extract clauses, assess risk levels, and identify key provisions in this document. + We'll read every clause, identify the risky ones, and flag anything that needs your attention.

- {/* Activity Feed */} - {activities.length > 0 && ( - -
-
-
- -

Recent Activity

- {activities.length} events -
-
-
- {activities.map((act, i) => ( - -
- {act.action === 'uploaded' && } - {act.action === 'deleted' && } - {act.action === 'chat_question' && } - {act.action === 'extraction_started' && } - {act.action === 'report_generated' && } - {!['uploaded', 'deleted', 'chat_question', 'extraction_started', 'report_generated'].includes(act.action) && ( - - )} -
-
-

- {act.action.replace(/_/g, ' ')} - {act.filename && ( - <> - {' — '} - {act.document_id ? ( - - ) : ( - {act.filename} - )} - - )} - {act.action === 'chat_question' && typeof act.details?.question === 'string' && ( - - "{act.details.question.slice(0, 80)}{act.details.question.length > 80 ? '...' : ''}" - - )} -

-

- {formatRelativeTime(act.created_at)} -

-
-
- ))} -
-
-
- )}
void }) { - const Wrapper = onClick ? motion.button : motion.div - return ( - -
-
- {icon} -
- {onClick && ( - - )} -
-
-

+ if (onClick) { + return ( +

-
+ +

{label}

+ + ) + } + return ( +
+ + {value.toLocaleString()} + +

{label}

+
) } diff --git a/frontend/src/app/documents/[id]/graph/page.tsx b/frontend/src/app/documents/[id]/graph/page.tsx index 78e6702..4b92e4d 100644 --- a/frontend/src/app/documents/[id]/graph/page.tsx +++ b/frontend/src/app/documents/[id]/graph/page.tsx @@ -127,15 +127,36 @@ export default function GraphPage() { const pollIntervalRef = useRef | null>(null) const pollTimeoutRef = useRef | null>(null) + const elapsedIntervalRef = useRef | null>(null) + const extractionStartRef = useRef(null) + const [extractionElapsed, setExtractionElapsed] = useState(0) // Cleanup polling on unmount useEffect(() => { return () => { if (pollIntervalRef.current) clearInterval(pollIntervalRef.current) if (pollTimeoutRef.current) clearTimeout(pollTimeoutRef.current) + if (elapsedIntervalRef.current) clearInterval(elapsedIntervalRef.current) } }, []) + // Track elapsed time during extraction + useEffect(() => { + if (extracting) { + extractionStartRef.current = Date.now() + setExtractionElapsed(0) + elapsedIntervalRef.current = setInterval(() => { + setExtractionElapsed(Math.floor((Date.now() - (extractionStartRef.current ?? Date.now())) / 1000)) + }, 1000) + } else { + if (elapsedIntervalRef.current) clearInterval(elapsedIntervalRef.current) + extractionStartRef.current = null + } + return () => { + if (elapsedIntervalRef.current) clearInterval(elapsedIntervalRef.current) + } + }, [extracting]) + const triggerExtraction = async () => { setExtracting(true) try { @@ -813,10 +834,9 @@ export default function GraphPage() {
-

No Entities Extracted

+

No knowledge graph yet

- Extract entities and relationships from this document to visualize - the knowledge graph. + Build the graph for this contract - extract parties, dates, amounts, and the relationships between them.

diff --git a/frontend/src/app/documents/[id]/page.tsx b/frontend/src/app/documents/[id]/page.tsx index 9d03675..68c9b3f 100644 --- a/frontend/src/app/documents/[id]/page.tsx +++ b/frontend/src/app/documents/[id]/page.tsx @@ -703,7 +703,7 @@ export default function DocumentDetailPage() {
- {clauseTypeLabels[clause.clause_type] || clause.clause_type.replace(/_/g, ' ')} + {clauseTypeLabels[clause.clause_type] || (clause.clause_type ?? '').replace(/_/g, ' ')} {clause.risk_level} @@ -930,7 +930,7 @@ export default function DocumentDetailPage() { {clause.risk_level}
- {clause.clause_type} + {clauseTypeLabels[clause.clause_type] || (clause.clause_type ?? '').replace(/_/g, ' ')}

{clause.summary}

diff --git a/frontend/src/lib/navigation.tsx b/frontend/src/lib/navigation.tsx index c83ee80..21f2c83 100644 --- a/frontend/src/lib/navigation.tsx +++ b/frontend/src/lib/navigation.tsx @@ -3,7 +3,7 @@ import { usePathname } from 'next/navigation' import Link from 'next/link' import Image from 'next/image' -import { LayoutDashboard, BarChart3, GitCompareArrows, Search, Menu, X, Sun, Moon, ClipboardCheck, Briefcase } from 'lucide-react' +import { LayoutDashboard, BarChart3, GitCompareArrows, Menu, X, Sun, Moon, ClipboardCheck, Briefcase } from 'lucide-react' import { useState } from 'react' import { useTheme } from '@/lib/theme' @@ -13,7 +13,6 @@ const navLinks = [ { href: '/compare', label: 'Compare', icon: GitCompareArrows }, { href: '/obligations', label: 'Obligations', icon: ClipboardCheck }, { href: '/deals', label: 'Deals', icon: Briefcase }, - { href: '/search', label: 'Search', icon: Search }, ] export function Navigation({ children }: { children?: React.ReactNode }) { @@ -36,7 +35,7 @@ export function Navigation({ children }: { children?: React.ReactNode }) { />
BrightClause -

Contract Intelligence

+

Contract Intelligence

diff --git a/frontend/src/lib/walkthrough.tsx b/frontend/src/lib/walkthrough.tsx index 4b21a02..83b3575 100644 --- a/frontend/src/lib/walkthrough.tsx +++ b/frontend/src/lib/walkthrough.tsx @@ -14,32 +14,32 @@ interface WalkthroughStep { const WALKTHROUGH_STEPS: WalkthroughStep[] = [ { target: '[data-tour="stats"]', - title: 'Portfolio Overview', - description: 'Real-time statistics showing your indexed documents, text chunks with vector embeddings, extracted clauses, and documents ready for review.', + title: 'Your contract portfolio', + description: 'A live snapshot of every contract in your portfolio - how many you have, how many clauses have been found, and which ones are ready to review.', position: 'bottom', }, { target: '[data-tour="search"]', - title: 'Semantic Search', - description: 'Search across all contracts using natural language. Our hybrid search combines AI semantic understanding with keyword matching for precise results.', + title: 'Search across all contracts', + description: 'Ask a question in plain English and find the answer across every contract at once. Try: "does any contract limit our liability to under $50k?"', position: 'bottom', }, { target: '[data-tour="upload"]', - title: 'Upload Contracts', - description: 'Upload PDF contracts for automated processing. Documents are parsed, chunked, embedded, and analyzed by our AI pipeline.', + title: 'Add a contract', + description: 'Drop any PDF contract here. Within a few minutes, every clause is read, categorized by type, and given a risk rating - automatically.', position: 'bottom', }, { target: '[data-tour="documents"]', - title: 'Contract Portfolio', - description: 'Click any document to view its AI risk assessment. Each contract is analyzed for clause types, risk levels, and key provisions.', + title: 'Click to see risk', + description: 'Click any contract to see its overall risk rating. Contracts with critical or high-risk clauses are flagged so you know where to focus first.', position: 'right', }, { target: '[data-tour="analysis"]', - title: 'Risk Assessment Panel', - description: 'AI-powered analysis shows overall risk, risk distribution, and highlights clauses that need attention. Click "View Full Analysis" for detailed clause-by-clause review.', + title: 'Risk breakdown', + description: 'The risk panel shows the overall exposure and which specific clauses need attention. Click "View Full Analysis" to read each flagged clause with a plain-English explanation.', position: 'left', }, ] @@ -179,8 +179,8 @@ export function WalkthroughOverlay({
- - Step {step + 1} of {totalSteps} + + {step + 1} of {totalSteps}