/** * entrypoints/content.tsx * * Content script injected into every supported page. * Responsibilities: * 1. Detect platform (X, YouTube, Instagram, ChatGPT, Claude, Gemini, web) * 2. MutationObserver: watch for new text nodes (≥12 words) * 3. Ring buffer + xxhash dedup → batch sends to background every 1200ms * 4. Range.surroundContents() highlighting with elements * 5. Shadow DOM hover cards (Framer Motion animated) * 6. Viewport-clamped card positioning */ import { defineContentScript } from 'wxt/sandbox'; import { createRoot } from 'react-dom/client'; import React, { useEffect, useRef, useState, useCallback } from 'react'; // ── Platform detection ──────────────────────────────────────────────────────── type Platform = | 'x' | 'instagram' | 'youtube' | 'chatgpt' | 'claude' | 'gemini' | 'web'; function detectPlatform(): Platform { const host = window.location.hostname; if (host.includes('twitter.com') || host.includes('x.com')) return 'x'; if (host.includes('instagram.com')) return 'instagram'; if (host.includes('youtube.com')) return 'youtube'; if (host.includes('chat.openai.com')) return 'chatgpt'; if (host.includes('claude.ai')) return 'claude'; if (host.includes('gemini.google.com') || host.includes('bard.google.com')) return 'gemini'; return 'web'; } // ── Color config ───────────────────────────────────────────────────────────── const COLOR_MAP: Record = { green: { bg: 'rgba(34,197,94,0.12)', border: '#22c55e', label: 'Verified', emoji: '✓' }, yellow:{ bg: 'rgba(234,179,8,0.14)', border: '#eab308', label: 'Unverified', emoji: '~' }, red: { bg: 'rgba(239,68,68,0.16)', border: '#ef4444', label: 'Misleading', emoji: '✗' }, purple:{ bg: 'rgba(168,85,247,0.15)', border: '#a855f7', label: 'AI Hallucination', emoji: '?' }, }; // ── Mode filtering ──────────────────────────────────────────────────────────── function shouldShowColor(color: string, mode: string): boolean { if (mode === 'minimal') return color === 'red' || color === 'purple'; if (mode === 'normal') return color === 'red' || color === 'purple' || color === 'yellow'; return true; // advanced: show all } // ── xxhash-like 32-bit hash (no WASM dep for content script) ───────────────── function fastHash(str: string): string { let h = 0x811c9dc5; for (let i = 0; i < str.length; i++) { h ^= str.charCodeAt(i); h = (h * 0x01000193) >>> 0; } return h.toString(16).padStart(8, '0'); } // ── Analysis result cache ───────────────────────────────────────────────────── const resultCache = new Map(); // ── Hover Card React component (Shadow DOM mounted) ─────────────────────────── interface HoverCardProps { result: typeof resultCache extends Map ? V : never; anchorRect: DOMRect; onDismiss: () => void; } function HoverCard({ result, anchorRect, onDismiss }: HoverCardProps) { const cardRef = useRef(null); const [pos, setPos] = useState({ top: 0, left: 0 }); const [visible, setVisible] = useState(false); const colorCfg = COLOR_MAP[result.color] || COLOR_MAP.yellow; useEffect(() => { // Calculate viewport-clamped position const vw = window.innerWidth, vh = window.innerHeight; const cardW = 340, cardH = 220; let top = anchorRect.bottom + 8; let left = anchorRect.left; if (top + cardH > vh) top = anchorRect.top - cardH - 8; if (left + cardW > vw) left = vw - cardW - 12; if (left < 8) left = 8; setPos({ top, left }); // Trigger enter animation requestAnimationFrame(() => setVisible(true)); }, [anchorRect]); const handleMouseLeave = useCallback(() => { setVisible(false); setTimeout(onDismiss, 180); }, [onDismiss]); return (
{/* Header */}
{colorCfg.emoji}
{colorCfg.label}
{result.verdict}
{/* Confidence arc */}
{/* Body */}

{result.explanation}

{/* Trust score bar */}
Trust
{Math.round(result.trustScore * 100)}%
{/* Sources */} {result.sources.length > 0 && (
{result.sources.slice(0, 3).map((url, i) => { const domain = (() => { try { return new URL(url).hostname.replace('www.',''); } catch { return url; } })(); return ( (e.currentTarget.style.background = 'rgba(96,165,250,0.12)')} onMouseLeave={e => (e.currentTarget.style.background = 'rgba(96,165,250,0.06)')} > { (e.target as HTMLImageElement).style.display = 'none'; }} /> {domain} ); })}
)}
{/* Footer */}
⚡ Fact Engine {result.confidence}% confidence
); } function ConfidenceArc({ confidence, color }: { confidence: number; color: string }) { const r = 13, cx = 18, cy = 18; const circumference = 2 * Math.PI * r; const dash = (confidence / 100) * circumference; return ( {confidence} ); } // ── Shadow DOM card host ────────────────────────────────────────────────────── let shadowHost: HTMLElement | null = null; let shadowRoot: ShadowRoot | null = null; let reactRoot: ReturnType | null = null; let activeCard: { dismiss: () => void } | null = null; function ensureShadowHost() { if (shadowHost && document.contains(shadowHost)) return; shadowHost = document.createElement('fact-engine-overlay'); shadowHost.style.cssText = 'position:fixed;top:0;left:0;width:0;height:0;z-index:2147483647;pointer-events:none;'; document.body.appendChild(shadowHost); shadowRoot = shadowHost.attachShadow({ mode: 'closed' }); const mountPoint = document.createElement('div'); mountPoint.style.pointerEvents = 'auto'; shadowRoot.appendChild(mountPoint); // Inject Tailwind + custom styles into shadow root const style = document.createElement('style'); style.textContent = ` @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap'); * { box-sizing: border-box; } a { cursor: pointer; } `; shadowRoot.insertBefore(style, mountPoint); reactRoot = createRoot(mountPoint); } function showCard(result: NonNullable>, anchorRect: DOMRect) { ensureShadowHost(); const dismiss = () => { reactRoot?.render(null); activeCard = null; }; activeCard?.dismiss(); activeCard = { dismiss }; reactRoot!.render( ); } // ── Mark highlight wrapper ──────────────────────────────────────────────────── function highlightRange(range: Range, color: string, hash: string) { const colorCfg = COLOR_MAP[color]; if (!colorCfg) return; try { const mark = document.createElement('mark'); mark.dataset.factHash = hash; mark.dataset.factColor = color; mark.style.cssText = ` background: ${colorCfg.bg} !important; border-bottom: 1.5px solid ${colorCfg.border} !important; border-radius: 2px !important; cursor: pointer !important; padding: 0 1px !important; text-decoration: none !important; `; mark.addEventListener('mouseenter', (e) => { const result = resultCache.get(hash); if (result) showCard(result, (e.target as HTMLElement).getBoundingClientRect()); }); range.surroundContents(mark); } catch (_) { // Range may span multiple nodes — skip to avoid DOM corruption } } // ── Text extraction from DOM ────────────────────────────────────────────────── const SKIP_TAGS = new Set(['SCRIPT', 'STYLE', 'SVG', 'NOSCRIPT', 'TEMPLATE', 'CODE', 'TEXTAREA']); const MIN_WORDS = 12; function extractTextNodes(root: Node): Text[] { const results: Text[] = []; const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, { acceptNode(node) { const parent = node.parentElement; if (!parent) return NodeFilter.FILTER_REJECT; if (SKIP_TAGS.has(parent.tagName)) return NodeFilter.FILTER_REJECT; if (parent.dataset.factHash) return NodeFilter.FILTER_REJECT; const text = node.textContent?.trim() || ''; if (text.split(/\s+/).length < MIN_WORDS) return NodeFilter.FILTER_SKIP; return NodeFilter.FILTER_ACCEPT; }, }); let node: Node | null; while ((node = walker.nextNode())) results.push(node as Text); return results; } // ── Main content script ─────────────────────────────────────────────────────── export default defineContentScript({ matches: [ '*://*.twitter.com/*', '*://*.x.com/*', '*://*.instagram.com/*', '*://*.youtube.com/*', '*://chat.openai.com/*', '*://claude.ai/*', '*://gemini.google.com/*', '*://*.reuters.com/*', '*://*.bbc.com/*', '*://*.apnews.com/*', '', ], runAt: 'document_idle', main() { const platform = detectPlatform(); const sentHashes = new Set(); // Ring buffer: cap at 50 pending claims let pendingBatch: Array<{ text: string; hash: string }> = []; let flushTimer: ReturnType; let enabled = true; let mode = 'normal'; // ── Load settings from storage ───────────────────────────────────────── chrome.storage.sync.get(['fact-engine-store'], (data) => { try { const stored = JSON.parse(data['fact-engine-store'] || '{}'); const state = stored.state || {}; enabled = state.enabled ?? true; mode = state.mode ?? 'normal'; } catch (_) {} }); // ── Listen for settings changes ──────────────────────────────────────── chrome.storage.onChanged.addListener((changes) => { if (changes['fact-engine-store']) { try { const state = JSON.parse(changes['fact-engine-store'].newValue || '{}').state || {}; enabled = state.enabled ?? enabled; mode = state.mode ?? mode; } catch (_) {} } }); // ── Receive analysis results from background ─────────────────────────── chrome.runtime.onMessage.addListener((msg) => { if (msg.type === 'analysis_results') { const results = msg.results as Array<{ claim_hash: string; claim_text: string; color: string; confidence: number; verdict: string; explanation: string; sources: string[]; trust_score: number; }>; for (const r of results) { resultCache.set(r.claim_hash, { color: r.color, confidence: r.confidence, verdict: r.verdict, explanation: r.explanation, sources: r.sources, trustScore: r.trust_score, }); // Apply highlights if mode allows this color if (enabled && shouldShowColor(r.color, mode)) { applyHighlightsForHash(r.claim_hash, r.color); } } } }); // ── Apply highlights to previously seen text nodes ───────────────────── const nodeRegistry = new Map(); // hash → Range function applyHighlightsForHash(hash: string, color: string) { const range = nodeRegistry.get(hash); if (range) { highlightRange(range, color, hash); nodeRegistry.delete(hash); } // Also scan DOM for already-rendered text with this hash document.querySelectorAll(`[data-fact-pending="${hash}"]`).forEach((el) => { (el as HTMLElement).style.borderBottom = `1.5px solid ${COLOR_MAP[color]?.border}`; (el as HTMLElement).dataset.factColor = color; }); } // ── Queue text for analysis ──────────────────────────────────────────── function queueTextNode(node: Text) { const text = node.textContent?.trim() || ''; const hash = fastHash(text); if (sentHashes.has(hash)) return; sentHashes.add(hash); // Store range for later highlighting try { const range = document.createRange(); range.selectNodeContents(node); nodeRegistry.set(hash, range); } catch (_) {} if (pendingBatch.length < 50) { pendingBatch.push({ text, hash }); } } // ── Flush batch to background ────────────────────────────────────────── function flushBatch() { if (!enabled || pendingBatch.length === 0) return; const batch = pendingBatch.splice(0, 20); // send max 20 at a time chrome.runtime.sendMessage({ type: 'analyze_claims', claims: batch.map(b => b.text), platform, }).catch(() => {}); } // ── MutationObserver ─────────────────────────────────────────────────── const observer = new MutationObserver((mutations) => { if (!enabled) return; for (const mutation of mutations) { if (mutation.type === 'childList') { for (const node of mutation.addedNodes) { const textNodes = extractTextNodes(node); textNodes.forEach(queueTextNode); } } } }); observer.observe(document.body, { childList: true, subtree: true, }); // ── Initial scan ─────────────────────────────────────────────────────── setTimeout(() => { if (!enabled) return; extractTextNodes(document.body).slice(0, 100).forEach(queueTextNode); }, 1500); // ── Ring buffer flush: every 1200ms ──────────────────────────────────── flushTimer = setInterval(flushBatch, 1200); }, });