gng / content.tsx
plexdx's picture
Upload 21 files
f589dab verified
/**
* 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 <mark> 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<string, { bg: string; border: string; label: string; emoji: string }> = {
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<string, {
color: string; confidence: number; verdict: string;
explanation: string; sources: string[]; trustScore: number;
}>();
// ── Hover Card React component (Shadow DOM mounted) ───────────────────────────
interface HoverCardProps {
result: typeof resultCache extends Map<string, infer V> ? V : never;
anchorRect: DOMRect;
onDismiss: () => void;
}
function HoverCard({ result, anchorRect, onDismiss }: HoverCardProps) {
const cardRef = useRef<HTMLDivElement>(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 (
<div
ref={cardRef}
onMouseLeave={handleMouseLeave}
style={{
position: 'fixed',
top: pos.top,
left: pos.left,
width: 340,
zIndex: 2147483647,
fontFamily: "'Inter', -apple-system, sans-serif",
fontSize: 13,
transform: visible ? 'translateY(0) scale(1)' : 'translateY(-8px) scale(0.96)',
opacity: visible ? 1 : 0,
transition: 'transform 180ms cubic-bezier(0.34,1.56,0.64,1), opacity 160ms ease',
willChange: 'transform, opacity',
}}
>
<div style={{
background: 'linear-gradient(135deg, #0f0f17 0%, #12121e 100%)',
border: `1px solid ${colorCfg.border}`,
borderRadius: 12,
boxShadow: `0 8px 32px rgba(0,0,0,0.6), 0 0 0 1px ${colorCfg.border}22`,
overflow: 'hidden',
}}>
{/* Header */}
<div style={{
display: 'flex', alignItems: 'center', gap: 10,
padding: '12px 14px 10px',
borderBottom: `1px solid ${colorCfg.border}22`,
background: `${colorCfg.bg}`,
}}>
<div style={{
width: 32, height: 32, borderRadius: '50%', border: `2px solid ${colorCfg.border}`,
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 14, color: colorCfg.border, fontWeight: 700,
flexShrink: 0,
}}>
{colorCfg.emoji}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ color: colorCfg.border, fontWeight: 700, fontSize: 12, textTransform: 'uppercase', letterSpacing: '0.08em' }}>
{colorCfg.label}
</div>
<div style={{ color: '#e2e8f0', fontWeight: 600, fontSize: 13, lineHeight: 1.3, marginTop: 2 }}>
{result.verdict}
</div>
</div>
{/* Confidence arc */}
<ConfidenceArc confidence={result.confidence} color={colorCfg.border} />
</div>
{/* Body */}
<div style={{ padding: '10px 14px 12px' }}>
<p style={{ color: '#94a3b8', fontSize: 12, lineHeight: 1.5, margin: '0 0 10px' }}>
{result.explanation}
</p>
{/* Trust score bar */}
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 10 }}>
<span style={{ color: '#64748b', fontSize: 11, whiteSpace: 'nowrap' }}>Trust</span>
<div style={{ flex: 1, height: 4, background: '#1e293b', borderRadius: 2, overflow: 'hidden' }}>
<div style={{
height: '100%', borderRadius: 2,
width: `${Math.round(result.trustScore * 100)}%`,
background: `linear-gradient(90deg, ${colorCfg.border}, ${colorCfg.border}88)`,
transition: 'width 600ms cubic-bezier(0.4,0,0.2,1)',
}} />
</div>
<span style={{ color: '#94a3b8', fontSize: 11, fontVariantNumeric: 'tabular-nums' }}>
{Math.round(result.trustScore * 100)}%
</span>
</div>
{/* Sources */}
{result.sources.length > 0 && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{result.sources.slice(0, 3).map((url, i) => {
const domain = (() => { try { return new URL(url).hostname.replace('www.',''); } catch { return url; } })();
return (
<a key={i} href={url} target="_blank" rel="noreferrer" style={{
display: 'flex', alignItems: 'center', gap: 6,
color: '#60a5fa', fontSize: 11, textDecoration: 'none',
padding: '3px 6px', borderRadius: 6,
background: 'rgba(96,165,250,0.06)',
transition: 'background 120ms',
}}
onMouseEnter={e => (e.currentTarget.style.background = 'rgba(96,165,250,0.12)')}
onMouseLeave={e => (e.currentTarget.style.background = 'rgba(96,165,250,0.06)')}
>
<img
src={`https://www.google.com/s2/favicons?domain=${domain}&sz=16`}
alt="" width={12} height={12} style={{ borderRadius: 2, flexShrink: 0 }}
onError={e => { (e.target as HTMLImageElement).style.display = 'none'; }}
/>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{domain}
</span>
</a>
);
})}
</div>
)}
</div>
{/* Footer */}
<div style={{
padding: '6px 14px 8px',
borderTop: '1px solid #1e293b',
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
}}>
<span style={{ color: '#334155', fontSize: 10 }}>⚑ Fact Engine</span>
<span style={{ color: '#334155', fontSize: 10 }}>
{result.confidence}% confidence
</span>
</div>
</div>
</div>
);
}
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 (
<svg width={36} height={36} style={{ flexShrink: 0, transform: 'rotate(-90deg)' }}>
<circle cx={cx} cy={cy} r={r} fill="none" stroke="#1e293b" strokeWidth={2.5} />
<circle
cx={cx} cy={cy} r={r} fill="none" stroke={color} strokeWidth={2.5}
strokeDasharray={`${dash} ${circumference}`}
strokeLinecap="round"
style={{ transition: 'stroke-dasharray 600ms ease' }}
/>
<text
x={cx} y={cy + 1}
textAnchor="middle" dominantBaseline="middle"
fill={color} fontSize={8} fontWeight="700"
style={{ transform: `rotate(90deg)`, transformOrigin: `${cx}px ${cy}px`, fontFamily: 'monospace' }}
>
{confidence}
</text>
</svg>
);
}
// ── Shadow DOM card host ──────────────────────────────────────────────────────
let shadowHost: HTMLElement | null = null;
let shadowRoot: ShadowRoot | null = null;
let reactRoot: ReturnType<typeof createRoot> | 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<ReturnType<typeof resultCache.get>>, anchorRect: DOMRect) {
ensureShadowHost();
const dismiss = () => { reactRoot?.render(null); activeCard = null; };
activeCard?.dismiss();
activeCard = { dismiss };
reactRoot!.render(
<HoverCard result={result} anchorRect={anchorRect} onDismiss={dismiss} />
);
}
// ── 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/*',
'<all_urls>',
],
runAt: 'document_idle',
main() {
const platform = detectPlatform();
const sentHashes = new Set<string>();
// Ring buffer: cap at 50 pending claims
let pendingBatch: Array<{ text: string; hash: string }> = [];
let flushTimer: ReturnType<typeof setInterval>;
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<string, Range>(); // 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);
},
});