-
- Velocity
+ ) : null}
+
+
+
+
+
+
+
Continue working
+
Recently added and processed documents.
+
+
- {delta && delta !== '—' ? (
- Change vs last period: {delta}
+
+ {isLoadingDocuments && !recentDocuments.length ? (
+ Loading documents…
+ ) : !recentDocuments.length ? (
+ Your workspace is ready.
Upload a document to begin building searchable knowledge.
) : (
- No change recorded
+
+ {recentDocuments.map((document) => )}
+
)}
+
+
-
-
- );
-}
-function HighlightCard({ title, value, helper }: { title: string; value: string; helper: string }) {
- return (
-
-
-
-
-
-
-
+
+
+
+
+
Ask your workspace
+
Get an answer grounded in the documents your team has already reviewed.
+
+
Ask a question…
+
+
+
+
Try asking
+
+ {['Which contracts renew this quarter?', 'Summarize the open compliance risks'].map((prompt) => (
+
+
{prompt}
+
+ ))}
+
+
+
+
+
+
-
{helper}
-
-
+
+
);
}
-function AttentionCard({
- documents,
- overdueTotal,
- dueSoonTotal,
- view,
- onChangeView,
-}: {
- documents: DocumentEntry[];
- overdueTotal: number;
- dueSoonTotal: number;
- view: 'overdue' | 'upcoming';
- onChangeView: (view: 'overdue' | 'upcoming') => void;
-}) {
+function PulseRow({ label, value, detail }: { label: string; value: string | number; detail: string }) {
return (
-
-
-
-
- Documents needing attention
- Route or follow up before SLAs slip.
-
-
-
-
-
-
-
-
- {documents.length === 0 ? (
-
- {view === 'overdue' ? 'No overdue documents right now.' : 'No documents due in the next 48 hours.'}
-
- ) : (
- documents.slice(0, 6).map((doc) => {
- const role = inferRole(doc);
- const status = inferStatus(doc);
- const dueAt = formatDateTime(inferDueDate(doc));
- return (
-
-
-
-
{doc.filename ?? doc.document_id}
-
- {role}
-
-
-
Status: {status}
-
Due: {dueAt}
-
-
-
- );
- })
- )}
-
-
+
+ {label}
+ {value}{detail}
+
);
}
-function SummaryProgressCard({ completed, total }: { completed: number; total: number }) {
- const percentage = total > 0 ? Math.round((completed / total) * 100) : 0;
+function HeroStat({ label, value, helper }: { label: string; value: string; helper: string }) {
return (
-
-
- Summary coverage
-
- {total > 0
- ? `${completed.toLocaleString()} of ${total.toLocaleString()} documents have summaries ready.`
- : 'No documents ingested yet.'}
-
-
-
-
-
- {completed.toLocaleString()} ready
- {Math.max(total - completed, 0).toLocaleString()} pending review
- {total.toLocaleString()} total uploaded
-
-
-
+
+
{label}
+
{value}{helper}
+
);
}
-function EmbeddingCoverageCard({ chunkCount, embeddedCount }: { chunkCount: number; embeddedCount: number }) {
- const coverage = chunkCount > 0 ? Math.round((embeddedCount / chunkCount) * 100) : 0;
+function DocumentRow({ document }: { document: DocumentEntry }) {
+ const status = inferStatus(document);
+ const role = inferRole(document);
+ const isReady = status.toLowerCase() === 'ready';
return (
-
-
-
- Embedding coverage
- {coverage}%
-
- Share of chunks with embeddings for search.
-
-
-
-
- {embeddedCount.toLocaleString()} embedded chunks
- {chunkCount.toLocaleString()} total processed
-
-
-
+
+
+
+ {document.filename ?? document.document_id}{document.doc_type ?? 'Unclassified'} · Added {formatDateTime(document.uploaded_at)}
+
+
+
);
}
-function QueueHealthCard({ totalQueued, overdue, dueSoon }: { totalQueued: number; overdue: number; dueSoon: number }) {
- const onTrack = Math.max(totalQueued - overdue - dueSoon, 0);
- const totalBar = Math.max(totalQueued, 1);
- const segments = [
- { label: 'Overdue', value: overdue, color: '#d97706' },
- { label: 'Due soon', value: dueSoon, color: '#3a7ca5' },
- { label: 'On track', value: onTrack, color: '#2f6690' },
- ];
-
+function AttentionPanel({ documents, riskCount }: { documents: DocumentEntry[]; riskCount: number }) {
return (
-
-
- Queue status
- Breakdown of items awaiting action.
-
-
- {totalQueued === 0 ? (
-
- All caught up—no queue today.
-
- ) : (
- <>
-
- {segments
- .filter(({ value }) => value > 0)
- .map(({ label, value, color }) => (
-
- ))}
-
-
- {segments.map(({ label, value, color }) => (
-
-
-
- {label}
-
-
- {value.toLocaleString()} ({Math.round((value / totalBar) * 100)}%)
-
-
- ))}
-
- >
- )}
-
-
+
+ Needs attention
{riskCount > 0 ? `${riskCount} ${riskCount === 1 ? 'review is' : 'reviews are'} approaching SLA` : 'No urgent reviews'}
+
+ {documents.length ? documents.map((document) => (
+
+
{document.filename ?? document.document_id}Due {formatDateTime(inferDueDate(document))}
+
+ )) :
The queue is clear. New SLA risks will appear here.
}
+
+
+
);
}
-function TrendCard({ title, series }: { title: string; series: Array<{ label: string; value: number }> }) {
- const values = series.length ? series.map((item) => item.value) : [10, 18, 12, 20, 17, 24, 19];
- const max = Math.max(...values, 1);
- const chartPoints = values
- .map((value, index) => {
- const x = (index / Math.max(values.length - 1, 1)) * 100;
- const y = 100 - (value / max) * 80 - 10;
- return `${x},${y}`;
- })
- .join(' ');
-
- const labels = series.length ? series.map((item) => item.label) : ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
-
+function CoveragePanel({ summaryCoverage, embeddingCoverage, metrics }: { summaryCoverage: number; embeddingCoverage: number; metrics: DashboardInsights }) {
return (
-
-
- {title}
- Volume of documents processed over time.
-
-
-
-
- {labels.slice(-4).map((label) => (
- {label}
- ))}
-
-
-
+
+ Knowledge readiness
How much of your workspace can answer questions today.
+
+
+
+
+ Citations and page provenance enabled for indexed sources.
+
);
}
-function WorkflowOverviewCard({ summary }: { summary: RoleSummary }) {
- const total = ROLE_ORDER.reduce((acc, role) => acc + (summary[role]?.count ?? 0), 0);
- const palette = ['#2f6690', '#3a7ca5', '#16425b', '#81c3d7', '#1b4965', '#0d1f2d'];
- const radius = 15;
- const circumference = 2 * Math.PI * radius;
- let cumulative = 0;
-
- const segments = ROLE_ORDER.map((role, index) => {
- const value = summary[role]?.count ?? 0;
- const dash = total > 0 ? (value / total) * circumference : 0;
- const percentage = total > 0 ? Math.round((value / total) * 100) : 0;
- const segment = {
- role,
- value,
- dash,
- offset: cumulative,
- color: palette[index % palette.length],
- percentage,
- };
- cumulative += dash;
- return segment;
- });
-
+function CoverageRow({ label, value, helper }: { label: string; value: number; helper: string }) {
return (
-
-
- Workflow by role
- Current queue mix and ownership across teams.
-
-
-
- {ROLE_ORDER.map((role) => (
-
-
{role}
-
-
{summary[role].count.toLocaleString()} in queue
-
{summary[role].overdue.toLocaleString()} overdue
-
-
- ))}
-
-
- {total === 0 ? (
-
- No active queue items.
-
- ) : (
- <>
-
-
- {segments.map(({ role, value, color, percentage }) => (
-
-
-
- {role}
-
-
- {value.toLocaleString()} ({percentage}%)
-
-
- ))}
-
- >
- )}
-
-
-
+
{label}{helper} · {value}%
);
}
-function OpsInsightsCard({
- total,
- completed,
- overdue,
- dueSoon,
-}: {
- total: number;
- completed: number;
- overdue: number;
- dueSoon: number;
-}) {
- const completionRate = total > 0 ? Math.round((completed / total) * 100) : 0;
- const remaining = Math.max(total - completed, 0);
- const automationRate = total > 0 ? Math.round((completed / Math.max(total, 1)) * 100) : 0;
- const tiles = [
- {
- label: 'Summary completion',
- value: `${completionRate}%`,
- detail: `${completed.toLocaleString()} ready · ${remaining.toLocaleString()} pending`,
- },
- {
- label: 'Routing backlog',
- value: overdue > 0 ? `${overdue}` : 'On track',
- detail:
- overdue > 0
- ? dueSoon > 0
- ? `${dueSoon} due within 48h`
- : 'Escalate overdue items'
- : dueSoon > 0
- ? `${dueSoon} due within 48h`
- : 'Nothing queued',
- },
- {
- label: 'Automation coverage',
- value: `${automationRate}%`,
- detail: total > 0 ? 'Completed via AI summarisation' : 'Awaiting uploads',
- },
- ];
-
- return (
-
-
- Operations insights
- Quick health check on throughput and backlog.
-
-
- {tiles.map(({ label, value, detail }) => (
-
-
-
- {label}
-
- {detail}
-
-
{value}
-
- ))}
-
-
- );
+function percentage(value: number, total: number) {
+ return total > 0 ? Math.round((value / total) * 100) : 0;
}
diff --git a/frontend/src/pages/LandingPage.tsx b/frontend/src/pages/LandingPage.tsx
new file mode 100644
index 0000000..38aa034
--- /dev/null
+++ b/frontend/src/pages/LandingPage.tsx
@@ -0,0 +1,291 @@
+import { type ReactNode, useEffect, useState } from 'react';
+import { Link } from 'react-router-dom';
+import {
+ ArrowRight,
+ BrainCircuit,
+ Check,
+ ChevronRight,
+ CircleCheck,
+ Database,
+ FileCheck2,
+ FileSearch,
+ Github,
+ Layers3,
+ MessageSquareText,
+ Network,
+ ScanLine,
+ Search,
+ ShieldCheck,
+ Sparkles,
+ UploadCloud,
+ UsersRound,
+ WalletCards,
+ Workflow,
+ Zap,
+} from 'lucide-react';
+
+import { Logo } from '../components/brand/Logo';
+import { Button } from '../components/ui/button';
+import { ThemeToggle } from '../components/ui/theme-toggle';
+
+const capabilities = [
+ { icon: ScanLine, title: 'Structure-aware extraction', copy: 'Keep headings, tables, page numbers, and reading order intact through ingestion.' },
+ { icon: Search, title: 'Retrieval with intent', copy: 'Find the passages that answer a question, not merely the pages sharing its keywords.' },
+ { icon: MessageSquareText, title: 'Grounded document chat', copy: 'Reason across long files while keeping every claim tied to its supporting context.' },
+ { icon: FileCheck2, title: 'Evidence by default', copy: 'Expose source document, page, chunk, and confidence alongside every generated answer.' },
+ { icon: Layers3, title: 'Multi-document synthesis', copy: 'Compare contracts, policies, reports, and research without losing provenance.' },
+ { icon: ShieldCheck, title: 'Operational control', copy: 'Track ingestion, classifications, reviews, retries, and lifecycle events in one workspace.' },
+];
+
+const useCases = [
+ { icon: FileCheck2, team: 'Legal', question: 'Where do renewal terms conflict across our vendor agreements?', outcome: 'Clause review with page-level evidence' },
+ { icon: ShieldCheck, team: 'Compliance', question: 'Which controls are incomplete, and what source proves it?', outcome: 'Auditable gap analysis' },
+ { icon: BrainCircuit, team: 'Research', question: 'What do these studies agree on—and where do they diverge?', outcome: 'Cross-document synthesis' },
+ { icon: WalletCards, team: 'Finance', question: 'What changed between the forecast and the board report?', outcome: 'Traceable variance analysis' },
+ { icon: UsersRound, team: 'People', question: 'Which policy applies to this employee request?', outcome: 'Fast policy navigation' },
+];
+
+const stack = ['React 19', 'TypeScript', 'FastAPI', 'Celery', 'Docling', 'PostgreSQL', 'pgvector', 'OpenAI'];
+
+export default function LandingPage() {
+ const [scrolled, setScrolled] = useState(false);
+
+ useEffect(() => {
+ const update = () => setScrolled(window.scrollY > 12);
+ update();
+ window.addEventListener('scroll', update, { passive: true });
+ return () => window.removeEventListener('scroll', update);
+ }, []);
+
+ return (
+
+
+
+
+
+
+
+
+
+ Open-source document intelligence
+
+
+ Ask your documents.
+ Inspect the evidence.
+
+
+ DocuLens turns complex files into cited, searchable knowledge—so your team can move faster without guessing what the model saw.
+
+
+
+ {['Page-level citations', 'Layout-aware ingestion', 'Provider flexible'].map((item) => {item})}
+
+
+
+
+
+
+
+
+
+
+
+
+
One evidence layer
+
Built for the moment an answer matters.
+
Extraction, retrieval, generation, and review belong in one coherent workflow. DocuLens keeps them connected through source provenance.
+
+
+
+ {capabilities.map(({ icon: Icon, title, copy }, index) => (
+
+
+ 0{index + 1}{title}
{copy}
+
+ ))}
+
+
+
+
+
+
+
+
From file to finding
A pipeline you can explain.
+
Every stage produces typed, inspectable outputs instead of hiding work inside one model call.
+
+
+ } title="Ingest" copy="Extract structure, OCR scans, and preserve page provenance." detail="Docling + validation" />
+ } title="Understand" copy="Create bounded chunks, batch embeddings, and retrieve with filters." detail="pgvector + evaluation" />
+ } title="Answer" copy="Construct grounded context and return evidence with confidence." detail="Structured model output" />
+
+
+
+
+
+
+
+
+
Trust is a product feature
+
Answers that show their work.
+
A fluent answer is not enough. DocuLens pairs each claim with source context, page metadata, retrieval confidence, and the original document.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Production-minded architecture
+
Fast at the edge. Durable underneath.
+
FastAPI accepts work quickly. Celery handles retryable document and model tasks. PostgreSQL remains the durable boundary for events, metadata, and vectors.
+
{stack.map((item) => {item})}
+
+
+
+
+
+
+
+
+
Evidence-heavy work
Useful wherever the source matters.
+
+ {useCases.map(({ icon: Icon, team, question, outcome }) => (
+
+
{team}
+ “{question}”
+ {outcome}
+
+ ))}
+
+
+
+
+
+
+
+
Make documents operational
Know what your documents know.
Run DocuLens locally, inspect the architecture, and ask your first evidence-grounded question.
+
+
+
+
+
+
+
+
+ );
+}
+
+function EvidenceCanvas() {
+ return (
+
+
+
+
+
+
+
+
Master services agreement
Acme Cloud, Inc.
18 / 28
+
12. Term and termination
+
This Agreement begins on the Effective Date and continues for the Initial Term.
Thereafter, the Agreement automatically renews for successive one-year periods unless either party provides written notice at least sixty (60) days before renewal.
Either party may terminate for material breach following a thirty-day cure period.
+
+
+
The agreement renews annually unless either party gives 60 days’ written notice. Material breach has a 30-day cure period.
+
+
+
+
+
+
+ );
+}
+
+function ProofPoint({ value, label }: { value: string; label: string }) {
+ return
;
+}
+
+function Eyebrow({ children }: { children: ReactNode }) {
+ return
{children}
;
+}
+
+function WorkflowStep({ index, icon, title, copy, detail }: { index: string; icon: ReactNode; title: string; copy: string; detail: string }) {
+ return
{icon}{index}
{title}
{copy}
{detail}
;
+}
+
+function EvidenceStory() {
+ return (
+
+
+
Q2 Board Report.pdf
41 pages · indexed 2 minutes ago
+
Question
What are the top risks to the forecast?
+
+
Grounded answer
High confidenceThe report identifies enterprise concentration, delayed European expansion, and model inference costs as the three material forecast risks.
+
+ );
+}
+
+function TrustRow({ title, copy }: { title: string; copy: string }) {
+ return
;
+}
+
+function ArchitectureMap() {
+ const nodes = [
+ { icon: Workflow, title: 'FastAPI', copy: 'Validated, versioned API' },
+ { icon: Zap, title: 'Celery', copy: 'Durable async workflows' },
+ { icon: Network, title: 'RAG pipeline', copy: 'Extract, chunk, retrieve' },
+ { icon: Database, title: 'Postgres + vectors', copy: 'Events and evidence' },
+ ];
+ return
{nodes.map(({ icon: Icon, title, copy }, index) =>
0{index + 1}
{title}
{copy}
)}
;
+}
+
+function SourcePill({ label }: { label: string }) {
+ return
{label};
+}
+
+function SourceCard({ index, title, page, score, active = false }: { index: string; title: string; page: string; score: string; active?: boolean }) {
+ return
;
+}
+
+function EvidenceMetric({ value, label }: { value: string; label: string }) {
+ return
;
+}
diff --git a/frontend/src/pages/LoginPage.tsx b/frontend/src/pages/LoginPage.tsx
index 02f90a5..3653af4 100644
--- a/frontend/src/pages/LoginPage.tsx
+++ b/frontend/src/pages/LoginPage.tsx
@@ -1,35 +1,29 @@
import { type FormEvent, useState } from 'react';
-import { Navigate, useLocation, useNavigate } from 'react-router-dom';
-import { Lock, LogIn, Mail } from 'lucide-react';
+import { Link, Navigate, useLocation, useNavigate } from 'react-router-dom';
+import { ArrowLeft, ArrowRight, Check, Eye, EyeOff, FileCheck2, LockKeyhole, Sparkles } from 'lucide-react';
import { useAuth } from '../auth/useAuth';
+import { Logo } from '../components/brand/Logo';
import { Button } from '../components/ui/button';
-import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../components/ui/card';
import { Input } from '../components/ui/input';
-import { Badge } from '../components/ui/badge';
+import { ThemeToggle } from '../components/ui/theme-toggle';
+import { useSettings } from '../settings/useSettings';
-const DEMO_ACCOUNTS = [
- { email: 'admin@doculens.ai', role: 'Admin', password: 'Admin!234' },
- { email: 'analyst@doculens.ai', role: 'Analyst', password: 'Analyst!234' },
- { email: 'reviewer@doculens.ai', role: 'Reviewer', password: 'Reviewer!234' },
- { email: 'manager@doculens.ai', role: 'Manager', password: 'Manager!234' },
- { email: 'developer@doculens.ai', role: 'Developer', password: 'Developer!234' },
- { email: 'viewer@doculens.ai', role: 'Viewer', password: 'Viewer!234' },
-];
+const DEMO_ACCOUNT = { email: 'analyst@doculens.ai', password: 'Analyst!234' };
export function LoginPage() {
const { user, login } = useAuth();
+ const { serverConfig } = useSettings();
const navigate = useNavigate();
const location = useLocation();
const fromState = (location.state as { from?: { pathname?: string } } | undefined)?.from;
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
+ const [showPassword, setShowPassword] = useState(false);
const [error, setError] = useState
(null);
const [isSubmitting, setIsSubmitting] = useState(false);
- if (user) {
- return ;
- }
+ if (user || serverConfig?.showcase_read_only) return ;
const handleSubmit = async (event: FormEvent) => {
event.preventDefault();
@@ -37,93 +31,50 @@ export function LoginPage() {
setIsSubmitting(true);
try {
await login(email, password);
- const redirectTo = fromState?.pathname ?? '/';
- navigate(redirectTo, { replace: true });
- } catch (err) {
- console.error(err);
- setError('We could not sign you in with those credentials.');
+ navigate(fromState?.pathname ?? '/app', { replace: true });
+ } catch {
+ setError('Those credentials do not match this workspace.');
setIsSubmitting(false);
}
};
- return (
-
-
-
- Sign in to DocuLens
-
- Secure access for analysts, reviewers, managers, and administrators.
-
-
-
-