# Project export: TruthLens

This document was generated by HackStack to give an AI agent context about a hackathon project. Sections are labeled with their provenance; content marked as truncated was cut to keep this document small.

## Project metadata

- Hackathon: TreeHacks 2026
- Tagline: Helping BILLIONS find objective truths on topics difficult to answer with a BIASED internet.
- Devpost: https://devpost.com/software/truthlens-u8exz1
- GitHub: https://github.com/rajabrehan/truthlens_treehacks
- Demo: https://truthlens-treehacks.vercel.app/
- Team: 0 GitHub contributor(s) — 

## Devpost submission (written by the team)

### Overview

🌍

### Inspiration

Over 52% of U.S. adults get news from social media, where misinformation spreads rapidly (Pew Research). Political content today is shaped by polarization, AI-generated misinformation, and narrative framing. Most tools try to declare what’s “true” or “false.” TruthLens reveals narrative structure instead. We quantify: Cross-source semantic agreement Narrative divergence Emotional intensity Source diversity 🧠 What It Does Paste a headline or URL and TruthLens: Scrapes parallel coverage across publishers Generates 768-dimensional semantic embeddings Clusters articles using Elasticsearch kNN Computes a transparent confidence score Visualizes narrative groups and bias distribution The dashboard includes: ⚙️ How We Built It 1️⃣ Bright Data SERP scraping Article HTML extraction Parallel coverage retrieval 2️⃣ Jina Embeddings (v3) 768-dimensional vectors Semantic representation of full article text 3️⃣ Elasticsearch (Elastic Cloud) dense_vector storage kNN vector search Similarity scoring Greedy clustering Metric aggregation 4️⃣ Deterministic Scoring Fully transparent. No opaque LLM reasoning. 🚧 Challenges Choosing the right vector similarity threshold Handling inconsistent article HTML structures Designing a neutral, defensible scoring formula Avoiding political labeling while preserving usefulness 🏆 Accomplishments Built a full ingest → embed → index → cluster → score → visualize pipeline Deep integration with Elasticsearch vector search Real-time parallel media coverage analysis Transparent, reproducible scoring system Production-ready dashboard UI 📚 What We Learned Vector search is powerful for narrative detection Agreement across independent sources is measurable Emotional intensity often correlates with divergence Deterministic AI builds trust 🚀 What’s Next 🌐 World heatmap of coverage origins 📈 Historical divergence tracking 🔔 Narrative shift detection 🧩 Chrome extension overlay 🔧 Full Technology List Frontend Next.js (App Router) React Tailwind CSS Lucide Icons Backend Next.js API Routes TypeScript Search & Storage Elasticsearch (Elastic Cloud) dense_vector kNN search Vector similarity scoring Index mappings Google Cloud Aggregations Embeddings Jina Embeddings v3 (768 dimensions) Data Acquisition Bright Data API SERP API Web Scraper API Analysis & Scoring Custom sentiment analysis logic Greedy threshold clustering algorithm Deterministic confidence scoring formula Elastic Cloud

## README (from the GitHub repository)

No README available.

## Detected evidence (automated analysis)

Indexed codebase: 25 recognized source files, 156 KB.
- CSS (language) — detected in the code
- Next.js (technology) — detected in the code
- React (technology) — detected in the code
- Tailwind CSS (technology) — detected in the code
- TypeScript (language) — detected in the code
- Node.js (technology) — claimed on Devpost, not found in the code
- AI coding agent: Cursor — evidence: commit authorship or trailers

## Codebase structure (from repository index)

### Files (32 of 32)

```
.gitignore
plan.md
truthlens/.env.example
truthlens/.gitignore
truthlens/eslint.config.mjs
truthlens/next.config.ts
truthlens/package.json
truthlens/postcss.config.mjs
truthlens/README.md
truthlens/src/app/api/analyze/route.ts
truthlens/src/app/globals.css
truthlens/src/app/layout.tsx
truthlens/src/app/page.tsx
truthlens/src/components/BiasSpectrum.tsx
truthlens/src/components/BothSides.tsx
truthlens/src/components/DeepContext.tsx
truthlens/src/components/ExplanationBanner.tsx
truthlens/src/components/Header.tsx
truthlens/src/components/KeyFacts.tsx
truthlens/src/components/KPICards.tsx
truthlens/src/components/NarrativeClusters.tsx
truthlens/src/components/SourcesTable.tsx
truthlens/src/components/WorldHeatmap.tsx
truthlens/src/lib/brightdata-mcp.ts
truthlens/src/lib/brightdata.ts
truthlens/src/lib/elasticsearch.ts
truthlens/src/lib/geo.ts
truthlens/src/lib/jina.ts
truthlens/src/lib/scoring.ts
truthlens/src/lib/types.ts
truthlens/src/types/react-simple-maps.d.ts
truthlens/tsconfig.json
```

### Dependencies

- truthlens/package.json: @elastic/elasticsearch@^9.3.1, @modelcontextprotocol/sdk@^1.26.0, @tailwindcss/postcss@^4, @types/node@^20, @types/react@^19, @types/react-dom@^19, clsx@^2.1.1, eslint@^9, eslint-config-next@16.1.6, lucide-react@^0.564.0, next@16.1.6, react@19.2.3, react-dom@19.2.3, react-simple-maps@^3.0.0, recharts@^3.7.0, tailwindcss@^4, typescript@^5

### Recent commits (newest first)

- Initial commit with full source
- Initial commit

## Key source files (fetched from GitHub, selected and truncated for size)

### plan.md

```markdown
# TruthLens — Real-Time Political Credibility & Bias Overlay
## (Public Truth Engine B)

---

## 0) One-Line Product

TruthLens is a real-time credibility and bias intelligence layer for political content.
It scrapes parallel coverage across outlets (Bright Data), indexes and clusters narratives using semantic vector search (Elasticsearch + Jina embeddings), computes transparent confidence metrics, and renders a clean dashboard or lightweight browser overlay.

TruthLens does not declare “truth.”
It quantifies cross-source agreement, emotional framing, source diversity, and narrative divergence — so users can think critically.

---

## 1) Why This Matters (Problem Framing)

Media polarization, AI-generated misinformation, and algorithmic echo chambers make it difficult for users to distinguish:
- Coordinated misinformation
- Emotionally manipulative framing
- Politically biased reporting
- Legitimate but divergent perspectives

Most Americans cannot reliably distinguish misinformation from partisan framing.
TruthLens does not censor. It reveals structure.

---

## 2) Sponsor Alignment (Mandatory)

### Bright Data (Web Intelligence Infrastructure)
TruthLens uses Bright Data to:
1. Scrape article HTML for any provided URL
2. Retrieve SERP results across multiple publishers
3. Fetch publisher metadata or domain enrichment signals

Bright Data enables unrestricted, parallel coverage extraction at runtime.

---

### Elasticsearch (End-to-End Agentic System)

Elasticsearch is the system of record for:

- Document storage
- Vector embeddings (dense_vector)
- kNN semantic retrieval
- Similarity scoring
- Clustering inputs
- Metric aggregation

mandatory:
- Depth and creativity of the Elasticsearch implementation 

- Use of JINA for embeddings 

- Use of Elastic Agent Builder and Workflows Use of Elastic Cloud



TruthLens implements a deterministic agentic workflow:

If article text missing → scrape.
If not enough coverage → broaden query.
If embeddings missing → embed.
If metrics missing → compute.
Then render structured results.

No heavy LLM reasoning required.

---

## 3) Differentiation 


TruthLens:
- Uses vector similarity to detect narrative convergence
- Measures emotional framing intensity
- Quantifies cross-source agreement
- Computes transparent confidence scoring formula
- Shows narrative clusters (not just bias bins)
- Can operate as a live overlay on tweets, YouTube videos, or news links

This is narrative structure detection, not political labeling.

---

## 4) Core User Experience

### User Flow
1. User pastes URL or headline OR activates Chrome overlay.
2. TruthLens fetches parallel coverage.
3. User sees structured dashboard:
   - Confidence score (0–100)
   - Bias distribution
   - Narrative divergence clusters
   - Emotional framing score
   - Source diversity

Optional:
- World heatmap of geographic source origin
- Trending topic indicator

---

## 5) UI Wireframe Specification

### Layout: Single Page App

#### Header
- TruthLens lo
[truncated — 4502 more characters]
```

### truthlens/package.json

```
{
  "name": "truthlens",
  "version": "0.1.0",
  "private": true,
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start",
    "lint": "eslint"
  },
  "dependencies": {
    "@elastic/elasticsearch": "^9.3.1",
    "@modelcontextprotocol/sdk": "^1.26.0",
    "clsx": "^2.1.1",
    "lucide-react": "^0.564.0",
    "next": "16.1.6",
    "react": "19.2.3",
    "react-dom": "19.2.3",
    "react-simple-maps": "^3.0.0",
    "recharts": "^3.7.0"
  },
  "devDependencies": {
    "@tailwindcss/postcss": "^4",
    "@types/node": "^20",
    "@types/react": "^19",
    "@types/react-dom": "^19",
    "eslint": "^9",
    "eslint-config-next": "16.1.6",
    "tailwindcss": "^4",
    "typescript": "^5"
  }
}

```

### truthlens/src/app/layout.tsx

```typescript
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";

const geistSans = Geist({
  variable: "--font-geist-sans",
  subsets: ["latin"],
});

const geistMono = Geist_Mono({
  variable: "--font-geist-mono",
  subsets: ["latin"],
});

export const metadata: Metadata = {
  title: "TruthLens — Real-Time Political Credibility & Bias Intelligence",
  description:
    "TruthLens quantifies cross-source agreement, emotional framing, source diversity, and narrative divergence for any political claim.",
};

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html lang="en" className="dark">
      <body
        className={`${geistSans.variable} ${geistMono.variable} font-sans antialiased`}
      >
        {children}
      </body>
    </html>
  );
}

```

### truthlens/src/app/page.tsx

```typescript
"use client";

import { useState, useEffect, useRef } from "react";
import Header from "@/components/Header";
import KPICards from "@/components/KPICards";
import BiasSpectrum from "@/components/BiasSpectrum";
import NarrativeClusters from "@/components/NarrativeClusters";
import SourcesTable from "@/components/SourcesTable";
import ExplanationBanner from "@/components/ExplanationBanner";
import WorldHeatmap from "@/components/WorldHeatmap";
import KeyFacts from "@/components/KeyFacts";
import BothSides from "@/components/BothSides";
import DeepContext from "@/components/DeepContext";
import type { AnalysisResult, PipelineStage } from "@/lib/types";
import { Zap, Shield, GitFork, Globe, Scale } from "lucide-react";

export default function Home() {
  const [result, setResult] = useState<AnalysisResult | null>(null);
  const [stage, setStage] = useState<PipelineStage>("idle");
  const [error, setError] = useState<string | null>(null);

  const handleAnalyze = async (input: string, type: "url" | "headline") => {
    setError(null);
    setResult(null);

    try {
      setStage("scraping");

      const response = await fetch("/api/analyze", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ input, type }),
      });

      if (!response.ok) {
        const errData = await response.json();
        throw new Error(errData.error || "Analysis failed");
      }

      setStage("analyzing");
      const data: AnalysisResult = await response.json();
      setResult(data);
      setStage("ready");
    } catch (err) {
      setError(err instanceof Error ? err.message : "An error occurred");
      setStage("error");
    }
  };

  return (
    <div className="min-h-screen bg-zinc-950 text-white">
      <Header onAnalyze={handleAnalyze} stage={stage} />

      <main className="mx-auto max-w-7xl px-4 py-8">
        {/* Empty State */}
        {!result && stage === "idle" && <EmptyState />}

        {/* Loading State */}
        {stage !== "idle" && stage !== "ready" && stage !== "error" && (
          <LoadingState stage={stage} />
        )}

        {/* Error State */}
        {error && (
          <div className="rounded-xl border border-red-500/30 bg-red-500/5 p-6 text-center">
            <p className="text-sm text-red-400">{error}</p>
            <p className="mt-2 text-xs text-zinc-500">
              Check your API keys and try again.
            </p>
          </div>
        )}

        {/* Results */}
        {result && (
          <div className="space-y-6 animate-in fade-in duration-500">
            {/* Query info */}
            <div className="rounded-xl border border-zinc-800 bg-zinc-900/30 px-5 py-3">
              <p className="text-xs text-zinc-500">
                Analyzing:{" "}
                <span className="text-zinc-300 font-medium">
                  {result.query.normalized_query}
                </span>
              </p>
            </div>

            {/* KPI Cards */}
            <KPICards query={result.query} />

            {/* Key Facts — prominent at the top */}
            <KeyFacts
              facts={result.key_facts}
              query={result.query.normalized_query}
            />

            {/* Deep Context — Why & How */}
            <DeepContext context={result.deep_context} />

            {/* Both Sides */}
            <BothSides perspectives={result.perspectives} />

            {/* Explanation */}
            <ExplanationBanner explanation={result.query.explanation} />

            {/* Main Grid */}
            <div className="grid grid-cols-1 gap-6 lg:grid-cols-5">
              {/* Left Column — 2/5 */}
              <div className="space-y-6 lg:col-span-2">
                <BiasSpectrum distribution={result.bias_distribution} />
                <NarrativeClusters clusters={result.clusters} />
              </div>

              {/* Right Column — 3/5 */}
              <div className="lg:col-span-3">
                <SourcesTable articles={result.articles} />
              </div>
            </div>

            {/* World Heatmap — Full Width */}
            <WorldHeatmap geoData={result.geo_data} />
          </div>
        )}
      </main>

      {/* Footer */}
      <footer className="border-t border-zinc-800 py-6 mt-12">
        <div className="mx-auto max-w-7xl px-4 text-center">
          <p className="text-xs text-zinc-600">
            TruthLens aggregates facts from diverse, independent sources and
            presents both sides — so you can see the full picture and think
            critically.
          </p>
          <p className="mt-2 text-[10px] text-zinc-700">
            Powered by Bright Data · Elasticsearch · Jina Embeddings
          </p>
        </div>
      </footer>
    </div>
  );
}

// ─── Empty State ────────────────────────────────────────────

function EmptyState() {
  return (
    <div className="flex flex-col items-center justify-center py-24">
      <div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-gradient-to-br from-emerald-500/20 to-cyan-500/20 border border-emerald-500/10 mb-6">
        <Zap className="h-8 w-8 text-emerald-400" />
      </div>
      <h2 className="text-2xl font-bold text-white mb-2">
        Understand the Why Behind Any Claim
      </h2>
      <p className="text-sm text-zinc-400 max-w-md text-center mb-8">
        Paste a headline, claim, or URL above. TruthLens digs into lobbying
        records, legislation, corporate interests, and diverse sources to show
        you objective facts from both sides — with full citations.
      </p>

      {/* Feature cards */}
      <div className="grid grid-cols-1 sm:grid-cols-4 gap-4 max-w-3xl w-full">
        {[
          {
            icon: Shield,
            title: "Follow the Money",
            desc: "Lobbying, donors, corporate interests behind decisions",
          },
          {
            icon: Scale,
            title: "Both Sides",
            desc: "See how d
[truncated — 5567 more characters]
```

### truthlens/src/app/api/analyze/route.ts

```typescript
import { NextRequest, NextResponse } from "next/server";
import {
  ensureIndices,
  bulkIndexArticles,
  indexQuery,
  geoAggregation,
} from "@/lib/elasticsearch";
import { fetchSerpResults, scrapeUrl, scrapeUrls } from "@/lib/brightdata";
import { generateEmbeddings, generateEmbedding } from "@/lib/jina";
import {
  analyzeSentiment,
  classifyLeaning,
  clusterArticles,
  computeBiasDistribution,
  computeConfidenceScore,
  computePerspectives,
  extractKeyFacts,
  extractDeepContext,
  generateAdjacentQueries,
  generateDeepSearchQueries,
} from "@/lib/scoring";
import type { Article, AnalysisResult } from "@/lib/types";
import { getCountryCentroid } from "@/lib/geo";
import type { SerpResult, ScrapedArticle } from "@/lib/brightdata";
import { mcpSearch, mcpScrapeUrls } from "@/lib/brightdata-mcp";

// ─── Generate UUID ──────────────────────────────────────────

function uuid(): string {
  return crypto.randomUUID();
}

// ─── Country heuristic from domain TLD ──────────────────────

const TLD_COUNTRY: Record<string, string> = {
  ".co.uk": "UK", ".uk": "UK", ".ca": "Canada", ".au": "Australia",
  ".de": "Germany", ".fr": "France", ".in": "India", ".jp": "Japan",
  ".br": "Brazil", ".mx": "Mexico", ".it": "Italy", ".es": "Spain",
  ".nl": "Netherlands", ".se": "Sweden", ".no": "Norway", ".dk": "Denmark",
  ".fi": "Finland", ".ie": "Ireland", ".nz": "New Zealand",
  ".za": "South Africa", ".sg": "Singapore", ".il": "Israel",
};

const DOMAIN_COUNTRY: Record<string, string> = {
  "cnn.com": "US", "foxnews.com": "US", "nytimes.com": "US",
  "washingtonpost.com": "US", "usatoday.com": "US", "nbcnews.com": "US",
  "cbsnews.com": "US", "abcnews.go.com": "US", "npr.org": "US",
  "politico.com": "US", "thehill.com": "US", "axios.com": "US",
  "huffpost.com": "US", "vox.com": "US", "breitbart.com": "US",
  "dailycaller.com": "US", "slate.com": "US", "theatlantic.com": "US",
  "apnews.com": "US", "pbs.org": "US", "msnbc.com": "US",
  "nypost.com": "US", "newsmax.com": "US", "dailywire.com": "US",
  "motherjones.com": "US", "thedailybeast.com": "US",
  "nationalreview.com": "US", "theintercept.com": "US",
  "businessinsider.com": "US", "bloomberg.com": "US",
  "wsj.com": "US", "fortune.com": "US", "time.com": "US",
  "opensecrets.org": "US", "congress.gov": "US", "govtrack.us": "US",
  "bbc.com": "UK", "bbc.co.uk": "UK", "theguardian.com": "UK",
  "telegraph.co.uk": "UK", "independent.co.uk": "UK",
  "dailymail.co.uk": "UK", "mirror.co.uk": "UK", "sky.com": "UK",
  "reuters.com": "UK", "ft.com": "UK", "economist.com": "UK",
  "cbc.ca": "Canada", "globalnews.ca": "Canada",
  "thestar.com": "Canada", "nationalpost.com": "Canada",
  "macleans.ca": "Canada", "ctv.ca": "Canada",
  "abc.net.au": "Australia", "smh.com.au": "Australia",
  "news.com.au": "Australia", "9news.com.au": "Australia",
  "sbs.com.au": "Australia",
  "aljazeera.com": "Qatar", "arabnews.com": "Saudi Arabia",
  "timesofisrael.com": "Israel", "haaretz.com": "Israel",
  "jpost.com": "Israel", "middleeasteye.net": "UK",
  "dw.com": "Germany", "spiegel.de": "Germany",
  "france24.com": "France", "lemonde.fr": "France",
  "ansa.it": "Italy", "elpais.com": "Spain",
  "rte.ie": "Ireland", "thejournal.ie": "Ireland",
  "dutchnews.nl": "Netherlands", "swissinfo.ch": "Switzerland",
  "scmp.com": "Hong Kong", "straitstimes.com": "Singapore",
  "timesofindia.indiatimes.com": "India", "ndtv.com": "India",
  "hindustantimes.com": "India", "japantimes.co.jp": "Japan",
  "koreaherald.com": "South Korea",
  "news24.com": "South Africa", "mg.co.za": "South Africa",
  "batimes.com.ar": "Argentina", "mexiconewsdaily.com": "Mexico",
};

const SOURCE_COUNTRY: Record<string, string> = {
  "BBC": "UK", "BBC News": "UK", "The Guardian": "UK", "Reuters": "UK",
  "The Telegraph": "UK", "The Independent": "UK", "Sky News": "UK",
  "Financial Times": "UK", "The Economist": "UK", "Daily Mail": "UK",
  "CNN": "US", "Fox News": "US", "The New York Times": "US",
  "The Washington Post": "US", "NBC News": "US", "CBS News": "US",
  "ABC News": "US", "NPR": "US", "Politico": "US", "The Hill": "US",
  "The Atlantic": "US", "AP News": "US", "PBS": "US", "MSNBC": "US",
  "New York Post": "US", "USA Today": "US", "Axios": "US",
  "Bloomberg": "US", "The Wall Street Journal": "US", "Time": "US",
  "Vox": "US", "HuffPost": "US", "Breitbart": "US", "Newsmax": "US",
  "OpenSecrets": "US", "Congress.gov": "US", "GovTrack": "US",
  "Al Jazeera": "Qatar", "Al Jazeera English": "Qatar",
  "France 24": "France", "DW": "Germany", "Deutsche Welle": "Germany",
  "CBC": "Canada", "CTV News": "Canada", "Global News": "Canada",
  "ABC Australia": "Australia", "SBS News": "Australia", "Sydney Morning Herald": "Australia",
  "South China Morning Post": "Hong Kong", "The Straits Times": "Singapore",
  "Times of India": "India", "NDTV": "India", "Hindustan Times": "India",
  "The Japan Times": "Japan", "Korea Herald": "South Korea",
  "Arab News": "Saudi Arabia", "Times of Israel": "Israel", "Haaretz": "Israel",
  "RTE": "Ireland", "The Irish Times": "Ireland",
};

function guessCountry(domain: string, source?: string): string {
  if (source) {
    const cleanSource = source.trim();
    if (SOURCE_COUNTRY[cleanSource]) return SOURCE_COUNTRY[cleanSource];
    for (const [pub, country] of Object.entries(SOURCE_COUNTRY)) {
      if (cleanSource.toLowerCase().includes(pub.toLowerCase())) return country;
    }
  }
  const clean = domain.replace(/^www\./, "").toLowerCase();
  if (DOMAIN_COUNTRY[clean]) return DOMAIN_COUNTRY[clean];
  for (const [tld, country] of Object.entries(TLD_COUNTRY)) {
    if (clean.endsWith(tld)) return country;
  }
  if (clean.endsWith(".com") || clean.endsWith(".org") || clean.endsWith(".net"))
    return "US";
  return "Unknown";
}

// ─── SERP + Scrape Helper ───────────────────────────────────
// Runs SERP queries, deduplicates, scrapes, and returns articles.
// Tracks which search layer found each result.

async function searchAndScrape(
  queries: { query: str
[truncated — 14270 more characters]
```

### truthlens/next.config.ts

```typescript
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  /* config options here */
};

export default nextConfig;

```

### truthlens/src/components/ExplanationBanner.tsx

```typescript
"use client";

import { Info } from "lucide-react";

interface ExplanationBannerProps {
  explanation: string;
}

export default function ExplanationBanner({
  explanation,
}: ExplanationBannerProps) {
  return (
    <div className="rounded-xl border border-zinc-800 bg-zinc-900/30 px-5 py-4">
      <div className="flex gap-3">
        <Info className="mt-0.5 h-4 w-4 shrink-0 text-emerald-400" />
        <div>
          <p className="text-xs font-semibold text-zinc-300 uppercase tracking-wider mb-1">
            How This Score Was Computed
          </p>
          <p className="text-xs text-zinc-400 leading-relaxed">
            {explanation}
          </p>
          <p className="mt-2 text-[10px] text-zinc-600 italic">
            Formula: confidence = cross-source agreement (cosine similarity across all source pairs)
          </p>
        </div>
      </div>
    </div>
  );
}

```

### truthlens/src/app/globals.css

```css
@import "tailwindcss";

:root {
  --background: #09090b;
  --foreground: #fafafa;
}

@theme inline {
  --color-background: var(--background);
  --color-foreground: var(--foreground);
  --font-sans: var(--font-geist-sans);
  --font-mono: var(--font-geist-mono);
}

body {
  background: var(--background);
  color: var(--foreground);
  font-family: var(--font-sans), system-ui, sans-serif;
}

/* Custom scrollbar */
::-webkit-scrollbar {
  width: 6px;
  height: 6px;
}

::-webkit-scrollbar-track {
  background: #18181b;
}

::-webkit-scrollbar-thumb {
  background: #3f3f46;
  border-radius: 3px;
}

::-webkit-scrollbar-thumb:hover {
  background: #52525b;
}

/* Animate in */
@keyframes fade-in {
  from {
    opacity: 0;
    transform: translateY(8px);
  }
  to {
    opacity: 1;
    transform: translateY(0);
  }
}

.animate-in {
  animation: fade-in 0.5s ease-out;
}

/* Shimmer for progress bar */
@keyframes shimmer {
  0% { background-position: 200% 0; }
  100% { background-position: -200% 0; }
}

```

### truthlens/src/types/react-simple-maps.d.ts

```typescript
declare module "react-simple-maps" {
  import { ComponentType, ReactNode, CSSProperties } from "react";

  interface ComposableMapProps {
    projectionConfig?: {
      scale?: number;
      center?: [number, number];
      rotate?: [number, number, number];
    };
    projection?: string;
    style?: CSSProperties;
    children?: ReactNode;
  }

  interface ZoomableGroupProps {
    center?: [number, number];
    zoom?: number;
    minZoom?: number;
    maxZoom?: number;
    children?: ReactNode;
  }

  interface GeographiesProps {
    geography: string | object;
    children: (args: { geographies: GeographyType[] }) => ReactNode;
  }

  interface GeographyType {
    rsmKey: string;
    properties: Record<string, unknown>;
    geometry: unknown;
  }

  interface GeographyProps {
    geography: GeographyType;
    fill?: string;
    stroke?: string;
    strokeWidth?: number;
    style?: {
      default?: CSSProperties;
      hover?: CSSProperties;
      pressed?: CSSProperties;
    };
  }

  interface MarkerProps {
    coordinates: [number, number];
    children?: ReactNode;
    onMouseEnter?: () => void;
    onMouseLeave?: () => void;
    onClick?: () => void;
    style?: {
      default?: CSSProperties;
      hover?: CSSProperties;
      pressed?: CSSProperties;
    };
  }

  export const ComposableMap: ComponentType<ComposableMapProps>;
  export const ZoomableGroup: ComponentType<ZoomableGroupProps>;
  export const Geographies: ComponentType<GeographiesProps>;
  export const Geography: ComponentType<GeographyProps>;
  export const Marker: ComponentType<MarkerProps>;
}

```

### truthlens/src/lib/jina.ts

```typescript
// ─── Jina Embeddings Integration ────────────────────────────
// Generates 768-dimensional embeddings using Jina Embeddings API

const JINA_API_KEY = () => process.env.JINA_API_KEY || "";

export async function generateEmbeddings(texts: string[]): Promise<number[][]> {
  const apiKey = JINA_API_KEY();
  if (!apiKey) throw new Error("JINA_API_KEY not configured");

  // Jina has a limit on input size, so we chunk if necessary
  const MAX_BATCH = 50;
  const allEmbeddings: number[][] = [];

  for (let i = 0; i < texts.length; i += MAX_BATCH) {
    const batch = texts.slice(i, i + MAX_BATCH);

    const response = await fetch("https://api.jina.ai/v1/embeddings", {
      method: "POST",
      headers: {
        Authorization: `Bearer ${apiKey}`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({
        model: "jina-embeddings-v3",
        task: "text-matching",
        dimensions: 768,
        late_chunking: false,
        embedding_type: "float",
        input: batch,
      }),
    });

    if (!response.ok) {
      const errText = await response.text();
      throw new Error(`Jina API error (${response.status}): ${errText}`);
    }

    const data = await response.json();
    const embeddings = data.data.map(
      (d: { embedding: number[] }) => d.embedding
    );
    allEmbeddings.push(...embeddings);
  }

  return allEmbeddings;
}

export async function generateEmbedding(text: string): Promise<number[]> {
  const results = await generateEmbeddings([text]);
  return results[0];
}

```

[15 more indexed source files omitted to keep this export small. The full file list is in the Codebase structure section above.]