Skip to content

Commit f0060e4

Browse files
m4cd4r4claude
andauthored
Design pipeline: UX overhaul across all app pages (#24)
* Design pipeline: UX overhaul across all app pages Full design critique and remediation pass across dashboard, analytics, compare, search, obligations, deals, document detail, and graph pages. Bug fixes: - Fix search highlightMatch regex stateful lastIndex bug - Fix mobile nav active state mismatch (startsWith vs exact match) - Add click-outside handlers for compare picker and export dropdown - Change modal backdrops to onMouseDown for touch device reliability - Fix document.addEventListener shadowing with window.document Empty states (delight): - Analytics: skeleton preview of stat cards + heatmap grid - Compare: side-by-side document skeleton preview - Deals: 3-column capability grid with guided CTA - Obligations: filtered vs unfiltered states with clear-filters action - Search: shows failed query, mode, and mode-switching buttons - Graph: dynamic empty state with document filename Visual improvements: - Risk level border-l-4 accents on analytics stat cards and clause cards - Graph page: pinch-to-zoom, touch pan, mobile filter chips, bottom sheet - Varied motion choreography per page (scale, x-slide, opacity-only) - Page headers render instantly (no animation delay) - Standardized h1 sizing, empty state padding, main content padding - Obligations view button: title -> aria-label Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * E2E: add design pipeline verification tests and fix port config - Add 21 new tests covering all PR checklist items (instant headers, search highlight, mobile nav, compare picker, modal dismiss, empty states, risk borders, h1 sizing, motion variation) - Fix all test files to use port 3001 matching playwright.config.ts - Update empty state text assertions to match redesigned content - Fix obligations selector (title -> aria-label) All 84 chromium + 68 mobile-chrome tests passing. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 2dfde92 commit f0060e4

20 files changed

Lines changed: 1011 additions & 163 deletions

frontend/src/app/analytics/page.tsx

Lines changed: 83 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -162,41 +162,69 @@ export default function AnalyticsPage() {
162162

163163
<main id="main-content" className="max-w-[1920px] mx-auto px-4 sm:px-8 py-8">
164164
{/* Page Header */}
165-
<motion.div
166-
initial={{ opacity: 0, y: 20 }}
167-
animate={{ opacity: 1, y: 0 }}
168-
className="mb-8"
169-
>
165+
<div className="mb-8">
170166
<h1 className="font-display text-3xl font-bold tracking-tight text-ink-50">Portfolio Analytics</h1>
171167
<p className="text-sm text-ink-500 mt-1">
172168
Risk assessment across {portfolioStats.totalDocs} analyzed contracts
173169
</p>
174-
</motion.div>
170+
</div>
175171

176172
{docAnalyses.length === 0 ? (
177173
<motion.div
178174
initial={{ opacity: 0 }}
179175
animate={{ opacity: 1 }}
180-
className="card p-16 text-center"
176+
className="card p-10 sm:p-14"
181177
>
182-
<BarChart3 className="w-16 h-16 text-ink-700 mx-auto" />
183-
<h2 className="font-display text-xl font-semibold mt-6">No Analysis Data Yet</h2>
184-
<p className="text-ink-500 mt-2 max-w-md mx-auto">
185-
Upload and analyze contracts from the dashboard to see portfolio-wide risk analytics.
186-
</p>
187-
<Link href="/dashboard" className="inline-flex items-center gap-2 mt-6 px-6 py-3 bg-accent text-ink-950 font-semibold rounded-xl hover:bg-accent-light transition-colors">
188-
Go to Dashboard
189-
<ChevronRight className="w-4 h-4" />
190-
</Link>
178+
<div className="max-w-lg mx-auto text-center">
179+
{/* Mini preview of what analytics will show */}
180+
<div className="mb-8 opacity-40 pointer-events-none select-none" aria-hidden="true">
181+
{/* Skeleton stat cards */}
182+
<div className="grid grid-cols-4 gap-2 mb-4">
183+
{['bg-red-500/20', 'bg-orange-500/20', 'bg-amber-500/20', 'bg-emerald-500/20'].map((bg, i) => (
184+
<div key={i} className="rounded-lg border border-ink-800/30 p-3">
185+
<div className={`w-6 h-6 rounded ${bg} mb-2`} />
186+
<div className="h-5 w-8 bg-ink-800/50 rounded mb-1" />
187+
<div className="h-2 w-12 bg-ink-800/30 rounded" />
188+
</div>
189+
))}
190+
</div>
191+
{/* Skeleton heatmap grid */}
192+
<div className="rounded-lg border border-ink-800/30 p-3">
193+
<div className="grid grid-cols-6 gap-1.5">
194+
{Array.from({ length: 18 }).map((_, i) => (
195+
<div
196+
key={i}
197+
className="h-5 rounded-sm"
198+
style={{
199+
backgroundColor: ['rgba(239,68,68,0.15)', 'rgba(245,158,11,0.15)', 'rgba(16,185,129,0.12)', 'rgba(99,102,106,0.08)'][i % 4],
200+
}}
201+
/>
202+
))}
203+
</div>
204+
</div>
205+
</div>
206+
207+
<BarChart3 className="w-10 h-10 text-ink-600 mx-auto" />
208+
<h2 className="font-display text-xl font-semibold mt-4">Your Portfolio Analytics</h2>
209+
<p className="text-ink-500 mt-2 max-w-md mx-auto text-sm leading-relaxed">
210+
Once you analyze your first contract, this page builds a risk heatmap, clause distribution chart,
211+
and health score across your entire portfolio. Start by uploading a contract from the dashboard.
212+
</p>
213+
<Link href="/dashboard" className="inline-flex items-center gap-2 mt-6 px-6 py-3 bg-accent text-ink-950 font-semibold rounded-xl hover:bg-accent-light transition-colors">
214+
Go to Dashboard
215+
<ChevronRight className="w-4 h-4" />
216+
</Link>
217+
</div>
191218
</motion.div>
192219
) : (
193220
<>
194221
{/* Health Score + Summary Stats */}
195222
<div className="grid grid-cols-2 lg:grid-cols-5 gap-4 mb-8">
196223
{/* Portfolio Health Score - Prominent */}
197224
<motion.div
198-
initial={{ opacity: 0, y: 20 }}
199-
animate={{ opacity: 1, y: 0 }}
225+
initial={{ opacity: 0, scale: 0.95 }}
226+
animate={{ opacity: 1, scale: 1 }}
227+
transition={{ type: 'spring', stiffness: 300, damping: 25 }}
200228
className="col-span-2 lg:col-span-1 card p-6"
201229
>
202230
<div className="text-center">
@@ -228,13 +256,20 @@ export default function AnalyticsPage() {
228256
</motion.div>
229257

230258
{/* Risk Level Stats */}
231-
{(['critical', 'high', 'medium', 'low'] as RiskLevel[]).map((level, i) => (
259+
{(['critical', 'high', 'medium', 'low'] as RiskLevel[]).map((level, i) => {
260+
const borderColor = {
261+
critical: 'border-red-500',
262+
high: 'border-orange-500',
263+
medium: 'border-amber-500',
264+
low: 'border-emerald-500',
265+
}[level]
266+
return (
232267
<motion.div
233268
key={level}
234-
initial={{ opacity: 0, y: 20 }}
235-
animate={{ opacity: 1, y: 0 }}
236-
transition={{ delay: 0.05 * (i + 1) }}
237-
className="card p-5"
269+
initial={{ opacity: 0, scale: 0.95 }}
270+
animate={{ opacity: 1, scale: 1 }}
271+
transition={{ delay: 0.05 * (i + 1), type: 'spring', stiffness: 300, damping: 25 }}
272+
className={`card p-5 border-l-4 ${borderColor}`}
238273
>
239274
<div className="flex items-start justify-between mb-2">
240275
<div className={`p-2 rounded-lg ${riskConfig[level].bg}/10`}>
@@ -251,14 +286,14 @@ export default function AnalyticsPage() {
251286
{riskConfig[level].label} Risk
252287
</p>
253288
</motion.div>
254-
))}
289+
)})}
255290
</div>
256291

257292
{/* Risk Heatmap */}
258293
<motion.div
259-
initial={{ opacity: 0, y: 20 }}
260-
animate={{ opacity: 1, y: 0 }}
261-
transition={{ delay: 0.2 }}
294+
initial={{ opacity: 0 }}
295+
animate={{ opacity: 1 }}
296+
transition={{ delay: 0.15, duration: 0.4 }}
262297
className="card overflow-hidden mb-8"
263298
>
264299
<div className="px-6 py-5 border-b border-ink-800/50 bg-ink-925">
@@ -352,9 +387,9 @@ export default function AnalyticsPage() {
352387
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
353388
{/* Clause Type Distribution */}
354389
<motion.div
355-
initial={{ opacity: 0, y: 20 }}
356-
animate={{ opacity: 1, y: 0 }}
357-
transition={{ delay: 0.3 }}
390+
initial={{ opacity: 0, x: -15 }}
391+
animate={{ opacity: 1, x: 0 }}
392+
transition={{ delay: 0.2, type: 'spring', stiffness: 200, damping: 22 }}
358393
className="card overflow-hidden"
359394
>
360395
<div className="px-6 py-5 border-b border-ink-800/50 bg-ink-925">
@@ -403,9 +438,9 @@ export default function AnalyticsPage() {
403438

404439
{/* Top Risk Highlights */}
405440
<motion.div
406-
initial={{ opacity: 0, y: 20 }}
407-
animate={{ opacity: 1, y: 0 }}
408-
transition={{ delay: 0.35 }}
441+
initial={{ opacity: 0, x: 15 }}
442+
animate={{ opacity: 1, x: 0 }}
443+
transition={{ delay: 0.25, type: 'spring', stiffness: 200, damping: 22 }}
409444
className="card overflow-hidden"
410445
>
411446
<div className="px-6 py-5 border-b border-ink-800/50 bg-ink-925">
@@ -421,13 +456,20 @@ export default function AnalyticsPage() {
421456
<p className="text-ink-500 text-sm mt-3">No high-risk clauses detected</p>
422457
</div>
423458
) : (
424-
allHighlights.slice(0, 15).map((highlight, i) => (
459+
allHighlights.slice(0, 15).map((highlight, i) => {
460+
const highlightBorder = {
461+
critical: 'border-red-500',
462+
high: 'border-orange-500',
463+
medium: 'border-amber-500',
464+
low: 'border-emerald-500',
465+
}[highlight.risk_level] || 'border-ink-700'
466+
return (
425467
<motion.div
426468
key={`${highlight.docId}-${highlight.clause_type}-${i}`}
427469
initial={{ opacity: 0 }}
428470
animate={{ opacity: 1 }}
429471
transition={{ delay: 0.4 + i * 0.03 }}
430-
className="px-6 py-4 hover:bg-ink-900/20 transition-colors"
472+
className={`px-6 py-4 hover:bg-ink-900/20 transition-colors border-l-4 ${highlightBorder}`}
431473
>
432474
<div className="flex items-start justify-between gap-3">
433475
<div className="flex-1 min-w-0">
@@ -459,7 +501,7 @@ export default function AnalyticsPage() {
459501
</Link>
460502
</div>
461503
</motion.div>
462-
))
504+
)})
463505
)}
464506
</div>
465507
</motion.div>
@@ -468,9 +510,9 @@ export default function AnalyticsPage() {
468510
{/* Cross-Document Entity References */}
469511
{crossRefs.length > 0 && (
470512
<motion.div
471-
initial={{ opacity: 0, y: 20 }}
472-
animate={{ opacity: 1, y: 0 }}
473-
transition={{ delay: 0.38 }}
513+
initial={{ opacity: 0 }}
514+
animate={{ opacity: 1 }}
515+
transition={{ delay: 0.3, duration: 0.4 }}
474516
className="card overflow-hidden mb-8"
475517
>
476518
<div className="px-6 py-5 border-b border-ink-800/50 bg-ink-925">
@@ -541,9 +583,9 @@ export default function AnalyticsPage() {
541583

542584
{/* Risk by Document - Summary Table */}
543585
<motion.div
544-
initial={{ opacity: 0, y: 20 }}
586+
initial={{ opacity: 0, y: 15 }}
545587
animate={{ opacity: 1, y: 0 }}
546-
transition={{ delay: 0.4 }}
588+
transition={{ delay: 0.35, type: 'spring', stiffness: 200, damping: 22 }}
547589
className="card overflow-hidden"
548590
>
549591
<div className="px-6 py-5 border-b border-ink-800/50 bg-ink-925">

frontend/src/app/compare/page.tsx

Lines changed: 63 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
'use client'
22

3-
import { useState, useEffect, useMemo, useCallback, Suspense } from 'react'
3+
import { useState, useEffect, useMemo, useCallback, useRef, Suspense } from 'react'
44
import { useSearchParams, useRouter } from 'next/navigation'
55
import { motion, AnimatePresence } from 'framer-motion'
66
import Link from 'next/link'
@@ -29,8 +29,21 @@ function ComparePageContent() {
2929
const [showPicker, setShowPicker] = useState(false)
3030
const [expandedCell, setExpandedCell] = useState<string | null>(null)
3131
const [initialized, setInitialized] = useState(false)
32+
const pickerRef = useRef<HTMLDivElement>(null)
3233
const { error: showError } = useToast()
3334

35+
// Close picker on outside click
36+
useEffect(() => {
37+
if (!showPicker) return
38+
const handleClick = (e: MouseEvent) => {
39+
if (pickerRef.current && !pickerRef.current.contains(e.target as Node)) {
40+
setShowPicker(false)
41+
}
42+
}
43+
document.addEventListener('mousedown', handleClick)
44+
return () => document.removeEventListener('mousedown', handleClick)
45+
}, [showPicker])
46+
3447
// Update URL whenever the selected doc IDs change
3548
const updateUrl = useCallback((docIds: string[]) => {
3649
const params = new URLSearchParams()
@@ -158,16 +171,12 @@ function ComparePageContent() {
158171

159172
<main id="main-content" className="max-w-[1920px] mx-auto px-4 sm:px-8 py-8">
160173
{/* Header */}
161-
<motion.div
162-
initial={{ opacity: 0, y: 20 }}
163-
animate={{ opacity: 1, y: 0 }}
164-
className="mb-8"
165-
>
174+
<div className="mb-8">
166175
<h1 className="font-display text-3xl font-bold tracking-tight text-ink-50">Document Comparison</h1>
167176
<p className="text-sm text-ink-500 mt-1">
168177
Compare clause coverage and risk levels across contracts side-by-side
169178
</p>
170-
</motion.div>
179+
</div>
171180

172181
{/* Selected Documents Bar */}
173182
<motion.div
@@ -205,7 +214,7 @@ function ComparePageContent() {
205214
))}
206215

207216
{compareDocs.length < 5 && (
208-
<div className="relative">
217+
<div ref={pickerRef} className="relative">
209218
<button
210219
type="button"
211220
onClick={() => setShowPicker(!showPicker)}
@@ -262,25 +271,55 @@ function ComparePageContent() {
262271
<motion.div
263272
initial={{ opacity: 0 }}
264273
animate={{ opacity: 1 }}
265-
className="card p-16 text-center"
274+
className="card p-10 sm:p-14"
266275
>
267-
<GitCompareArrows className="w-16 h-16 text-ink-700 mx-auto" />
268-
<h2 className="font-display text-xl font-semibold mt-6">
269-
{compareDocs.length === 0 ? 'Select Documents to Compare' : 'Add One More Document'}
270-
</h2>
271-
<p className="text-ink-500 mt-2 max-w-md mx-auto">
272-
{compareDocs.length === 0
273-
? 'Choose 2-5 contracts from your portfolio to compare their clause coverage, risk levels, and key provisions side-by-side.'
274-
: 'Select at least 2 documents to begin comparing clauses and risk levels.'
275-
}
276-
</p>
276+
<div className="max-w-lg mx-auto text-center">
277+
{/* Mini side-by-side comparison preview */}
278+
<div className="mb-8 opacity-40 pointer-events-none select-none" aria-hidden="true">
279+
<div className="flex gap-3 justify-center">
280+
{[0, 1].map((col) => (
281+
<div key={col} className="flex-1 max-w-[180px] rounded-lg border border-ink-800/30 p-3">
282+
<div className="flex items-center gap-2 mb-3">
283+
<FileText className="w-3.5 h-3.5 text-ink-600" />
284+
<div className="h-2.5 w-16 bg-ink-800/50 rounded" />
285+
</div>
286+
{Array.from({ length: 4 }).map((_, row) => (
287+
<div key={row} className="flex items-center gap-2 mb-2">
288+
<div className="h-2 w-14 bg-ink-800/30 rounded" />
289+
<div
290+
className="w-5 h-5 rounded-sm"
291+
style={{
292+
backgroundColor: col === 0
293+
? ['rgba(239,68,68,0.2)', 'rgba(245,158,11,0.2)', 'rgba(16,185,129,0.15)', 'rgba(99,102,106,0.1)'][row]
294+
: ['rgba(245,158,11,0.2)', 'rgba(16,185,129,0.15)', 'rgba(99,102,106,0.1)', 'rgba(239,68,68,0.2)'][row],
295+
}}
296+
/>
297+
</div>
298+
))}
299+
</div>
300+
))}
301+
</div>
302+
</div>
303+
304+
<GitCompareArrows className="w-10 h-10 text-ink-600 mx-auto" />
305+
<h2 className="font-display text-xl font-semibold mt-4">
306+
{compareDocs.length === 0 ? 'Compare Your Contracts' : 'Add One More Document'}
307+
</h2>
308+
<p className="text-ink-500 mt-2 max-w-md mx-auto text-sm leading-relaxed">
309+
{compareDocs.length === 0
310+
? 'Select 2-5 contracts to see a clause-by-clause comparison matrix. Spot coverage gaps, risk differences, and missing provisions across agreements.'
311+
: 'You need at least two documents to build the comparison matrix. Use the "Add Document" button above to pick another contract.'
312+
}
313+
</p>
314+
</div>
277315
</motion.div>
278316
) : (
279317
<>
280318
{/* Comparison Matrix */}
281319
<motion.div
282-
initial={{ opacity: 0, y: 20 }}
283-
animate={{ opacity: 1, y: 0 }}
320+
initial={{ opacity: 0, x: -20 }}
321+
animate={{ opacity: 1, x: 0 }}
322+
transition={{ type: 'spring', stiffness: 200, damping: 22 }}
284323
className="card overflow-hidden mb-8"
285324
>
286325
<div className="px-6 py-5 border-b border-ink-800/50 bg-ink-925">
@@ -406,9 +445,9 @@ function ComparePageContent() {
406445

407446
{/* Coverage Gaps */}
408447
<motion.div
409-
initial={{ opacity: 0, y: 20 }}
410-
animate={{ opacity: 1, y: 0 }}
411-
transition={{ delay: 0.1 }}
448+
initial={{ opacity: 0, x: 20 }}
449+
animate={{ opacity: 1, x: 0 }}
450+
transition={{ delay: 0.1, type: 'spring', stiffness: 200, damping: 22 }}
412451
className="card overflow-hidden"
413452
>
414453
<div className="px-6 py-5 border-b border-ink-800/50 bg-ink-925">

frontend/src/app/dashboard/page.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -426,7 +426,10 @@ function DashboardContent() {
426426
<main id="main-content" className="max-w-[1920px] mx-auto px-4 sm:px-8 py-8">
427427
<h1 className="sr-only">Contract Dashboard</h1>
428428
{/* Portfolio Stats Strip */}
429-
<div
429+
<motion.div
430+
initial={{ opacity: 0, scale: 0.98 }}
431+
animate={{ opacity: 1, scale: 1 }}
432+
transition={{ type: 'spring', stiffness: 300, damping: 25 }}
430433
className="grid grid-cols-3 divide-x divide-ink-800/40 border-b border-ink-800/40 mb-8"
431434
data-tour="stats"
432435
>
@@ -463,7 +466,7 @@ function DashboardContent() {
463466
/>
464467
</>
465468
)}
466-
</div>
469+
</motion.div>
467470

468471
{/* Search Results */}
469472
<AnimatePresence>

0 commit comments

Comments
 (0)