// Scribin' v2 — Your reading friend.
// Intake: PDF (incl. huge textbooks, chunk-loaded) · URLs · YouTube
// Memory: persistent library + position + past-session recall
// Companion Mode: she drops personable asides while reading
import React, { useState, useRef, useEffect, useCallback } from "react";
// ─────────────────────────────────────────────────────────────
// Design tokens — ash / burnt orange / green
const S = {
ash: "#1B1C1A",
ash2: "#252623",
ash3: "#33342F",
orange:"#C45C1A",
orangeHover:"#D9681F",
green: "#4A8C5C",
greenText:"#6BC488",
cardText:"#EDE8E0",
cardMuted:"#8C897F",
cardSub:"#5E605A",
scrim: "rgba(240,237,230,.55)",
};
// Text colors for reading view
const T = {
ink: "#EBDCC7",
paper:"#241812",
plum: "#8A5A3B",
gold: "#C89B6D",
sage: "#6F8A5C",
rose: "#A9563C",
line: "#3E2C20",
};
const DOCK = S;
const VIBES = {
Lecture: { rate:0.95, pitch:0.82, desc:"Steady, professorial cadence" },
Storytime: { rate:0.88, pitch:0.90, desc:"Warm, unhurried narration" },
Sprint: { rate:1.35, pitch:0.85, desc:"Fast review pass" },
"Late Night": { rate:0.80, pitch:0.75, desc:"Low, calm, easy on the ears" },
};
const FEMALE_HINTS = ["female","samantha","victoria","karen","moira","tessa","fiona","susan","zira","aria","jenny","michelle","serena","allison","ava","joanna","kate","emma","amy","salli","kimberly","nicole","catherine","hazel","heather","laura","libby","sonia","natasha","olivia"];
const isFemale = v => {
const n = v.name.toLowerCase();
if (FEMALE_HINTS.some(h => n.includes(h))) return true;
return false;
};
// ─────────────────────────────────────────────────────────────
// API calls — proxied through /api/anthropic on the server
const ANTHROPIC_BASE = '/api/anthropic';
async function askClaude(messages, system, useSearch = false) {
const body = { model: "claude-sonnet-4-20250514", max_tokens: 1000, system, messages };
if (useSearch) body.tools = [{ type: "web_search_20250305", name: "web_search" }];
const res = await fetch(`${ANTHROPIC_BASE}/v1/messages`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
const data = await res.json();
return (data.content || []).filter(b => b.type === "text").map(b => b.text).join("\n");
}
async function askClaudeHF(messages, system) {
const body = {
model: "claude-sonnet-4-20250514", max_tokens: 1000, system, messages,
mcp_servers: [{ type: "url", url: "https://huggingface.co/mcp", name: "huggingface-mcp" }]
};
const res = await fetch(`${ANTHROPIC_BASE}/v1/messages`, {
method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body)
});
const data = await res.json();
return (data.content || []).filter(b => b.type === "text").map(b => b.text).join("\n");
}
const splitSentences = txt =>
(txt.match(/[^.!?]+[.!?]+["')\]]*|\S[^.!?]*$/g) || []).map(s=>s.trim()).filter(s=>s.length>2);
const sid = name => "scr:doc:" + name.replace(/[\s/\\'"]+/g,"_").slice(0,150);
// ─────────────────────────────────────────────────────────────
export default function Scribin() {
const [docked, setDocked] = useState(true);
const [dockSide, setDockSide] = useState("right");
const [studio, setStudio] = useState(false);
const [overlay, setOverlay] = useState(false);
const [urlMode, setUrlMode] = useState("Article / PDF");
const [geauxing, setGeauxing] = useState(false);
const [hfOpen, setHfOpen] = useState(false);
const [hfInput, setHfInput] = useState("");
const [hfLog, setHfLog] = useState([]);
const [hfBusy, setHfBusy] = useState(false);
const [docName, setDocName] = useState(null);
const [sentences, setSentences] = useState([]);
const [cursor, setCursor] = useState(0);
const [playing, setPlaying] = useState(false);
const [voices, setVoices] = useState([]);
const [voiceURI, setVoiceURI] = useState(null);
const [rate, setRate] = useState(0.95);
const [pitch, setPitch] = useState(0.82);
const [vibe, setVibe] = useState("Lecture");
const [autoTune, setAutoTune] = useState(true);
const [companion, setCompanion] = useState(true);
const [tab, setTab] = useState("read");
const [verseLog, setVerseLog] = useState([]);
const [verseInput, setVerseInput] = useState("");
const [busy, setBusy] = useState(false);
const [glossary, setGlossary] = useState([]);
const [sparks, setSparks] = useState([]);
const [recap, setRecap] = useState("");
const [streak, setStreak] = useState(0);
const [loading, setLoading] = useState(null);
const [urlInput, setUrlInput] = useState("");
const [library, setLibrary] = useState([]);
const [aside, setAside] = useState(null);
const utterRef = useRef(null);
const cursorRef = useRef(0);
const playingRef = useRef(false);
const fileRef = useRef(null);
const scrollRef = useRef(null);
const asideCounter = useRef(0);
// ── voices ──
useEffect(() => {
const load = () => {
const all = window.speechSynthesis.getVoices();
const fem = all.filter(v=>v.lang.startsWith("en") && isFemale(v));
const pool = fem.length ? fem : all.filter(v=>v.lang.startsWith("en"));
setVoices(pool);
if (pool.length && !voiceURI) setVoiceURI(pool[0].voiceURI);
};
load(); window.speechSynthesis.onvoiceschanged = load;
return () => { window.speechSynthesis.onvoiceschanged = null; };
}, []);
// ── persistent library (localStorage fallback for non-HF environments) ──
const loadLibrary = async () => {
try {
if (window.storage?.get) {
const r = await window.storage.get("scr:library");
setLibrary(r ? JSON.parse(r.value) : []);
} else {
const stored = localStorage.getItem("scr:library");
setLibrary(stored ? JSON.parse(stored) : []);
}
} catch { setLibrary([]); }
};
useEffect(() => { loadLibrary(); }, []);
const saveDoc = async (name, sents, cur = 0, note = "") => {
try {
if (window.storage?.set) {
await window.storage.set(sid(name), JSON.stringify({ sents, cur }));
const lib = [{ name, total: sents.length, cur, note, ts: Date.now() },
...library.filter(l => l.name !== name)].slice(0, 25);
setLibrary(lib);
await window.storage.set("scr:library", JSON.stringify(lib));
} else {
localStorage.setItem(sid(name), JSON.stringify({ sents, cur }));
const lib = [{ name, total: sents.length, cur, note, ts: Date.now() },
...library.filter(l => l.name !== name)].slice(0, 25);
setLibrary(lib);
localStorage.setItem("scr:library", JSON.stringify(lib));
}
} catch {}
};
const savePos = async (cur) => {
if (!docName) return;
try {
if (window.storage?.get) {
const r = await window.storage.get(sid(docName));
if (r) {
const d = JSON.parse(r.value); d.cur = cur;
await window.storage.set(sid(docName), JSON.stringify(d));
const lib = library.map(l => l.name === docName ? { ...l, cur } : l);
setLibrary(lib);
await window.storage.set("scr:library", JSON.stringify(lib));
}
} else {
const stored = localStorage.getItem(sid(docName));
if (stored) {
const d = JSON.parse(stored); d.cur = cur;
localStorage.setItem(sid(docName), JSON.stringify(d));
const lib = library.map(l => l.name === docName ? { ...l, cur } : l);
setLibrary(lib);
localStorage.setItem("scr:library", JSON.stringify(lib));
}
}
} catch {}
};
const openFromLibrary = async (name) => {
try {
let r;
if (window.storage?.get) {
r = await window.storage.get(sid(name));
} else {
r = { value: localStorage.getItem(sid(name)) };
}
if (!r?.value) return;
const d = JSON.parse(r.value);
loadContent(name, d.sents, d.cur || 0, true);
} catch {}
};
const loadContent = (name, sents, cur = 0, fromMemory = false) => {
stopSpeech();
setSentences(sents); setDocName(name);
setCursor(cur); cursorRef.current = cur;
setDocked(false); setTab("read");
setVerseLog([]); setGlossary([]); setSparks([]); setRecap(""); setStreak(0);
if (!fromMemory) saveDoc(name, sents, cur);
if (fromMemory && cur > 0 && companion) {
speakOnce(`Welcome back. We were ${Math.round((cur/sents.length)*100)} percent through ${name}. Picking up where we left off.`);
}
};
// ── PDF (handles large textbooks: batched page extraction) ──
const ensurePdfJs = () => new Promise((res, rej) => {
if (window.pdfjsLib) return res(window.pdfjsLib);
const s = document.createElement("script");
s.src = "https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js";
s.onload = () => { window.pdfjsLib.GlobalWorkerOptions.workerSrc = "https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js"; res(window.pdfjsLib); };
s.onerror = rej; document.head.appendChild(s);
});
const handleFile = async (file) => {
if (!file) return;
setLoading("Opening PDF…");
try {
const pdfjs = await ensurePdfJs();
const pdf = await pdfjs.getDocument({ data: await file.arrayBuffer() }).promise;
const pgs = [];
const BATCH = 20;
for (let start = 1; start <= pdf.numPages; start += BATCH) {
const end = Math.min(start + BATCH - 1, pdf.numPages);
setLoading(`Reading pages ${start}–${end} of ${pdf.numPages}…`);
const batch = await Promise.all(
Array.from({ length: end - start + 1 }, (_, i) =>
pdf.getPage(start + i).then(p => p.getTextContent())
.then(tc => tc.items.map(it => it.str).join(" ").replace(/\s+/g," ").trim()))
);
pgs.push(...batch);
}
loadContent(file.name, splitSentences(pgs.join("\n\n")));
} catch { alert("Couldn't parse that PDF."); }
setLoading(null);
};
// ── URL / YouTube intake (via Claude + web search) ──
const isYouTube = u => /youtube\.com|youtu\.be/.test(u);
const geaux = async () => {
const url = urlInput.trim();
if (!url) return;
setOverlay(false); setGeauxing(true); setDocked(true);
setLoading(isYouTube(url) ? "fetching transcript…" : "fetching page…");
try {
const prompt = isYouTube(url)
? `Find and return the full spoken transcript of this YouTube video: ${url}. Output ONLY transcript text.`
: `Fetch this page and return its full readable body text: ${url}. Output ONLY the content.`;
const text = await askClaude([{role:"user",content:prompt}], "You extract clean readable text only.", true);
if (!text || text.length < 100) throw new Error("thin");
const name = isYouTube(url) ? "▶ " + url.split(/v=|be\//).pop().slice(0,20) : url.replace(/https?:\/\/(www\.)?/,"").slice(0,40);
const sents = splitSentences(text);
setSentences(sents); setDocName(name);
setCursor(0); cursorRef.current = 0;
saveDoc(name, sents, 0);
setUrlInput(""); setLoading(null);
playingRef.current = true; setPlaying(true);
speakOnce(`Alright, I've got it. Here's ${name}.`);
setTimeout(() => speakFrom(0), 2500);
} catch {
setLoading(null); setGeauxing(false);
speakOnce("I couldn't pull that one — it may be paywalled.");
}
};
const takeMeThere = async () => {
const url = urlInput.trim();
if (!url) return;
window.open(url, "_blank");
setOverlay(false);
await handleUrl();
};
const handleUrl = async () => {
const url = urlInput.trim();
if (!url) return;
setLoading(isYouTube(url) ? "Pulling video transcript…" : "Fetching article…");
try {
const prompt = isYouTube(url)
? `Find and return the full spoken transcript (or the most complete summary of everything said, in order) of this YouTube video: ${url}. Output ONLY the transcript text, no commentary, no timestamps.`
: `Fetch this page and return its full readable article/body text: ${url}. Output ONLY the text content, no commentary, no navigation junk.`;
const text = await askClaude([{ role:"user", content: prompt }], "You extract clean readable text. Output only the content itself.", true);
if (!text || text.length < 100) throw new Error("thin");
const name = isYouTube(url) ? "▶ " + url.split(/v=|be\//).pop().slice(0,20) : url.replace(/https?:\/\/(www\.)?/,"").slice(0,40);
loadContent(name, splitSentences(text));
setUrlInput("");
} catch { alert("Couldn't pull content from that link — it may be paywalled or transcript-less."); }
setLoading(null);
};
// ── speech ──
const speakOnce = (text) => {
const u = new SpeechSynthesisUtterance(text);
const v = voices.find(v=>v.voiceURI===voiceURI);
if (v) u.voice = v;
u.rate = rate; u.pitch = pitch;
window.speechSynthesis.speak(u);
};
const tuneFor = (text) => {
let r = rate, p = pitch;
if (!autoTune) return { r, p };
if (/[=∑∫∂λθ]|O\(/.test(text)) r = Math.max(0.65, r - 0.2);
if (/\?\s*$/.test(text)) p = Math.min(1.1, p + 0.08);
if (/\b(critically|importantly|note that|key|fundamental|however|therefore)\b/i.test(text)) r = Math.max(0.7, r - 0.1);
if (text.length < 40) r = Math.min(1.5, r + 0.05);
return { r, p };
};
const maybeAside = async (idx) => {
if (!companion) return null;
asideCounter.current++;
if (asideCounter.current % 12 !== 0) return null;
try {
const ctx = sentences.slice(Math.max(0, idx-12), idx+1).join(" ").slice(-2000);
const a = await askClaude(
[{ role:"user", content:`You're reading this aloud to a friend and just finished this stretch:\n"${ctx}"\nDrop ONE short spoken aside (max 18 words) like a friend would — a reaction, a "get this", a quick connection. Casual, warm, no quotes.` }],
"You are a warm, sharp woman in her 40s reading to a close friend. One short line only.");
return a?.trim()?.slice(0, 160) || null;
} catch { return null; }
};
const speakFrom = useCallback((idx) => {
window.speechSynthesis.cancel();
if (idx >= sentences.length) {
setPlaying(false); playingRef.current = false;
if (companion) speakOnce("And… that's the whole thing. Want to verse about it?");
return;
}
const text = sentences[idx];
const u = new SpeechSynthesisUtterance(text);
const v = voices.find(v=>v.voiceURI===voiceURI);
if (v) u.voice = v;
const { r, p } = tuneFor(text);
u.rate = r; u.pitch = p;
u.onend = async () => {
if (!playingRef.current) return;
const next = cursorRef.current + 1;
cursorRef.current = next; setCursor(next);
setStreak(s=>s+1);
if (next % 10 === 0) savePos(next);
const a = await maybeAside(next);
if (a && playingRef.current) {
setAside(a);
const au = new SpeechSynthesisUtterance(a);
if (v) au.voice = v;
au.rate = Math.min(1.2, r + 0.1); au.pitch = Math.min(1.1, p + 0.06);
au.onend = () => { setAside(null); if (playingRef.current) speakFrom(next); };
window.speechSynthesis.speak(au);
} else {
speakFrom(next);
}
};
utterRef.current = u;
window.speechSynthesis.speak(u);
}, [sentences, voices, voiceURI, rate, pitch, autoTune, companion]);
const play = () => { playingRef.current = true; setPlaying(true); speakFrom(cursorRef.current); };
const stopSpeech = () => { playingRef.current = false; setPlaying(false); setGeauxing(false); window.speechSynthesis.cancel(); savePos(cursorRef.current); };
const jump = (idx) => { cursorRef.current = idx; setCursor(idx); if (playingRef.current) speakFrom(idx); };
const applyVibe = (n) => { setVibe(n); setRate(VIBES[n].rate); setPitch(VIBES[n].pitch); };
// Smooth scroll to current sentence
useEffect(() => {
const el = document.getElementById(`sent-${cursor}`);
const box = scrollRef.current;
if (!el || !box) return;
const target = el.offsetTop - box.clientHeight * 0.42;
const start = box.scrollTop, dist = target - start;
if (Math.abs(dist) < 4) return;
const dur = 900; let t0 = null; let raf;
const ease = t => 1 - Math.pow(1 - t, 3);
const step = ts => {
if (!t0) t0 = ts;
const p = Math.min(1, (ts - t0) / dur);
box.scrollTop = start + dist * ease(p);
if (p < 1) raf = requestAnimationFrame(step);
};
raf = requestAnimationFrame(step);
return () => cancelAnimationFrame(raf);
}, [cursor]);
const readSoFar = () => sentences.slice(Math.max(0, cursor-60), cursor+1).join(" ").slice(-6000);
// ── study modes ──
const sendVerse = async () => {
if (!verseInput.trim() || busy) return;
const log = [...verseLog, { role:"user", content: verseInput.trim() }];
setVerseLog(log); setVerseInput(""); setBusy(true);
try {
const pastDocs = library.filter(l=>l.name!==docName).slice(0,5).map(l=>l.name).join("; ");
const system = `You are Scribin' — a warm, sharp woman in her 40s reading to a close friend. Passage just read aloud:\n"""${readSoFar()}"""\n${pastDocs ? `You've also read these with them before (you may reference them): ${pastDocs}.` : ""}\nDiscuss like a friend, under 150 words. Socratic, never condescending.`;
const reply = await askClaude(log.map(m=>({role:m.role,content:m.content})), system);
setVerseLog([...log, { role:"assistant", content: reply }]);
window.speechSynthesis.cancel(); speakOnce(reply);
} catch { setVerseLog(l=>[...l,{role:"assistant",content:"Lost my train of thought — ask again?"}]); }
setBusy(false);
};
const buildGlossary = async () => {
setBusy(true); setTab("glossary");
try {
const raw = await askClaude([{role:"user",content:`Extract the 8 most important technical terms from this passage; define each in one plain sentence. JSON only: [{"term":"...","def":"..."}]. Passage:\n${readSoFar()}`}], "Return only valid JSON.");
setGlossary(JSON.parse(raw.replace(/```json|```/g,"").trim()));
} catch { setGlossary([{term:"Oops",def:"Read a bit more first."}]); }
setBusy(false);
};
const buildSparks = async () => {
setBusy(true); setTab("spark");
try {
const raw = await askClaude([{role:"user",content:`4 quick quiz questions (recall + one thought experiment) on this passage. JSON only: [{"q":"...","a":"..."}]. Passage:\n${readSoFar()}`}], "Return only valid JSON.");
setSparks(JSON.parse(raw.replace(/```json|```/g,"").trim()).map(s=>({...s,open:false})));
} catch { setSparks([{q:"Read a little more, then spark me again.",a:"",open:false}]); }
setBusy(false);
};
const buildRecap = async () => {
setBusy(true); setTab("recap");
try {
const r = await askClaude([{role:"user",content:`Tight 5-bullet recap of this passage + one real-world connection. Passage:\n${readSoFar()}`}], "Warm, brilliant study partner. Concise.");
setRecap(r);
if (docName) {
const lib = library.map(l=>l.name===docName?{...l,note:r.slice(0,300)}:l);
setLibrary(lib);
if (window.storage?.set) {
window.storage.set("scr:library", JSON.stringify(lib)).catch(()=>{});
} else {
localStorage.setItem("scr:library", JSON.stringify(lib));
}
}
} catch { setRecap("Listen a bit longer first."); }
setBusy(false);
};
// ── HuggingFace Voice Agent ──
const HF_KEYS = ["live 4d human avatar", "2d audio driven", "real-time streaming diffusion", "autoregressive video", "causal dit", "digital human pipeline", "audio driven portrait", "talking head generation"];
const FAVORITES_PROMPT = HF_KEYS.map(k => `"${k}"`).join(", ");
const sendHf = async (overrideMsg) => {
const userMsg = (overrideMsg || hfInput).trim();
if (!userMsg || hfBusy) return;
const log = [...hfLog, { role:"user", content: userMsg }];
setHfLog(log); setHfInput(""); setHfBusy(true);
try {
const system = `You are Scribin\'s HuggingFace scout — a sharp, curious woman in her 40s who browses HuggingFace for TJ like a knowledgeable friend. You use the HuggingFace MCP to search models, spaces, and papers. When reading papers, focus on TJ\'s favorite research keywords: ${FAVORITES_PROMPT}. Narrate what you\'re looking at with commentary — not just facts, but your take. Keep replies under 200 words, spoken-word style. If you find something exciting, say so.`;
const reply = await askClaudeHF(log.map(m=>({role:m.role,content:m.content})), system);
setHfLog([...log, { role:"assistant", content: reply }]);
window.speechSynthesis.cancel();
speakOnce(reply);
} catch(e) {
const err = "Lost my connection to HuggingFace — try again in a sec.";
setHfLog(l=>[...l,{role:"assistant",content:err}]);
}
setHfBusy(false);
};
const QUICK_PROMPTS = [
"Browse the HuggingFace papers section and find the most exciting paper about live 4D human avatars or audio-driven portraits published this week. Read me the abstract and give me your take.",
"Search HuggingFace spaces for anything related to real-time talking head or audio-driven video generation. What\'s the most impressive one you see?",
"Find the latest trending models on HuggingFace related to 2D audio-driven animation or causal video diffusion. Describe what you\'re seeing.",
];
const MODES = { "Article / PDF":"https://arxiv.org/pdf/…", "YouTube":"https://youtube.com/watch?v=…", "HuggingFace":"https://huggingface.co/papers/…" };
const Overlay = () => overlay && (
setOverlay(false)} style={{position:"fixed",inset:0,zIndex:100,
background:S.scrim, backdropFilter:"blur(3px)",
display:"flex",alignItems:"center",justifyContent:"center",fontFamily:"system-ui,sans-serif"}}>
e.stopPropagation()} style={{background:S.ash,borderRadius:20,width:"92%",maxWidth:460,
padding:"26px 26px 20px",boxShadow:"0 24px 64px rgba(27,28,26,.35)"}}>
bring something in
What should she read?
Paste a link — she fetches and narrates, or takes you there with her.
{Object.keys(MODES).map(m=>(
setUrlMode(m)} style={{flex:1,padding:"7px 0",borderRadius:999,fontSize:12,fontWeight:500,
cursor:"pointer",fontFamily:"inherit",transition:"all .15s",
border:`0.5px solid ${urlMode===m?S.orange:S.ash3}`,
background:urlMode===m?S.orange:S.ash2,
color:urlMode===m?"#FFF5EE":S.cardMuted}}>{m}
))}
setUrlInput(e.target.value)}
onKeyDown={e=>e.key==="Enter"&&geaux()}
placeholder={MODES[urlMode]}
style={{flex:1,background:"none",border:"none",outline:"none",fontSize:14,color:S.cardText,fontFamily:"inherit"}} />
↗ Take me there
Geaux
she goes · you stay
◎
◎
Geaux means she fetches it and reads out loud while you stay here. Her voice comes through the dock.
);
const pct = sentences.length ? Math.round((cursor/sentences.length)*100) : 0;
// ── dock ──
if (docked) {
return (
handleFile(e.target.files[0])} />
{/* Main pill */}
docName && !geauxing ? setDocked(false) : setOverlay(true)}
style={{ cursor:"pointer", background:S.ash, color:S.cardText,
borderRadius:16, padding:"12px 18px",
display:"flex", alignItems:"center", gap:13,
boxShadow:`0 4px 24px rgba(0,0,0,.45), inset 0 0 0 1px rgba(196,92,26,.3)`,
border:"none", userSelect:"none" }}>
Scribin’
{loading || (geauxing ? "reading aloud…" : docName ? `${pct}% through` : "pdf · url · youtube")}
{geauxing ? (
Geaux
) : docName && (
▶
)}
{/* Enter Studio — admin panel trigger */}
setStudio(s=>!s)}
style={{display:"flex", alignItems:"center", justifyContent:"center", gap:6,
marginTop:7, cursor:"pointer", userSelect:"none"}}>
{studio ? "close studio" : "enter studio"}
{/* HuggingFace Voice Agent */}
{setHfOpen(o=>!o); if(!hfOpen && hfLog.length===0) sendHf("Introduce yourself and tell me what you can do with HuggingFace for me.");}}
style={{display:"flex", alignItems:"center", justifyContent:"center", gap:6,
marginTop:6, cursor:"pointer", userSelect:"none"}}>
hf scout {hfOpen ? "▴" : "▾"}
{hfOpen && (
hf scout
She browses Hugging Face and reads it to you.
{/* Quick prompts */}
{QUICK_PROMPTS.map((q,i)=>(
sendHf(q)} disabled={hfBusy}
style={{background:"rgba(196,92,26,.08)",border:"1px solid rgba(196,92,26,.2)",borderRadius:8,
padding:"7px 10px",color:S.orange,fontSize:11,cursor:hfBusy?"wait":"pointer",textAlign:"left",fontFamily:"inherit",lineHeight:1.4}}>
{["📄 Latest papers","🚀 Top spaces","🔥 Trending models"][i]} →
))}
{/* Log */}
{hfLog.map((m,i)=>(
{m.role==="user"?"You":"Scout"} — {m.content}
))}
{hfBusy &&
browsing HuggingFace…
}
{/* Input */}
setHfInput(e.target.value)} onKeyDown={e=>e.key==="Enter"&&sendHf()}
placeholder="find something…"
style={{flex:1,padding:"7px 10px",borderRadius:8,border:"1px solid rgba(196,92,26,.25)",
background:"rgba(255,255,255,.04)",color:S.cardText,fontFamily:"inherit",fontSize:12}} />
sendHf()} disabled={hfBusy}
style={{padding:"7px 12px",borderRadius:8,background:"rgba(196,92,26,.18)",color:S.orange,border:"1px solid rgba(196,92,26,.3)",
fontWeight:600,cursor:hfBusy?"wait":"pointer",fontSize:12,fontFamily:"inherit"}}>go
)}
{/* Studio panel */}
{studio && (
controls
vibe
applyVibe(e.target.value)}
style={{width:"100%",padding:"6px 8px",borderRadius:8,border:"1px solid rgba(196,92,26,.3)",background:"rgba(255,255,255,.05)",color:S.cardText,fontFamily:"inherit",marginBottom:10}}>
{Object.keys(VIBES).map(v=>{v} )}
speed · {rate.toFixed(2)}×
setRate(+e.target.value)}
style={{width:"100%",accentColor:S.orange,marginBottom:10}} />
Pitch · {pitch.toFixed(2)}
setPitch(+e.target.value)}
style={{width:"100%",accentColor:S.orange,marginBottom:10}} />
voice
setVoiceURI(e.target.value)}
style={{width:"100%",padding:"6px 8px",borderRadius:8,border:"1px solid rgba(196,92,26,.3)",background:"rgba(255,255,255,.05)",color:S.cardText,fontFamily:"inherit",marginBottom:10}}>
{voices.map(v=>{v.name} )}
setAutoTune(e.target.checked)} style={{accentColor:S.orange}} />
Smart Tune
setCompanion(e.target.checked)} style={{accentColor:S.orange}} />
Companion asides
position
{["left","right"].map(s=>(
setDockSide(s)}
style={{flex:1,padding:"6px 0",borderRadius:8,border:"1px solid rgba(196,92,26,.3)",
background:dockSide===s?S.orange:"transparent",color:dockSide===s?"#1C1F1E":S.cardText,
cursor:"pointer",fontFamily:"inherit",fontSize:12,fontWeight:600}}>{s}
))}
{library.length>0 && (
{library.length} doc{library.length!==1?"s":""} in memory
)}
)}
);
}
const Btn = ({children,onClick,active,disabled}) => (
{children}
);
return (
handleFile(e.target.files[0])} />
Scribin’
{docName || "no document"}
setOverlay(true)}>+ Bring something in
fileRef.current.click()}>📄 PDF
{stopSpeech();setDocked(true);}}>Dock ▾
{loading &&
⏳ {loading}
}
{aside &&
💛 "{aside}"
}
{sentences.length===0 ? (
📖
Give me a PDF, an article link, or a YouTube video — I'll read it to you like a friend would.
{library.length>0 && (
📚 Things we've read together
{library.map(l=>(
openFromLibrary(l.name)}
style={{background:"#2E2018",border:`1.5px solid ${T.line}`,borderRadius:10,padding:"12px 16px",marginBottom:8,cursor:"pointer",display:"flex",justifyContent:"space-between",alignItems:"center"}}>
{l.name}
{l.note &&
{l.note.slice(0,90)}…
}
{Math.round((l.cur/l.total)*100)}% · resume ▶
))}
)}
) : (
{sentences.map((s,i)=>(
jump(i)} style={{
cursor:"pointer",padding:"2px 4px",borderRadius:6,
transition:"background 1.1s ease, color .8s ease, box-shadow 1.1s ease",
background:i===cursor?"rgba(200,155,109,.22)":"transparent",
color:i===cursor?"#FFF3E2":(i{s}
))}
)}
jump(Math.max(0,cursor-3))}>⏪
{playing?"❚❚ Pause":"▶ Read to me"}
jump(Math.min(sentences.length-1,cursor+3))}>⏩
🔥 Streak: {streak} {pct}% · position saved
Voice (all women)
setVoiceURI(e.target.value)}
style={{width:"100%",padding:8,margin:"6px 0 14px",borderRadius:8,border:`1.5px solid ${T.line}`,background:"#2E2018",fontFamily:"inherit"}}>
{voices.map(v=>{v.name} )}
Vibe
{Object.keys(VIBES).map(v=>applyVibe(v)}>{v} )}
{VIBES[vibe].desc}
Speed · {rate.toFixed(2)}×
setRate(+e.target.value)} style={{width:"100%",accentColor:S.orange}} />
Pitch · {pitch.toFixed(2)} (lower = more mature)
setPitch(+e.target.value)} style={{width:"100%",accentColor:S.orange}} />
setAutoTune(e.target.checked)} style={{accentColor:T.gold}} />
🎚️ Smart Tune — slows for math, lifts for questions
setCompanion(e.target.checked)} style={{accentColor:T.gold}} />
💛 Companion Mode — she reacts like a friend while reading
Study modes
setTab("verse")}>💬 Verse
🔍 Decode
⚡ Spark
🧠 Recap
{tab==="verse" && (
She remembers this doc and your past reads. She answers out loud.
{verseLog.map((m,i)=>(
{m.role==="user"?"You":"Scribin'"} — {m.content}
))}
{busy &&
thinking…
}
setVerseInput(e.target.value)} onKeyDown={e=>e.key==="Enter"&&sendVerse()}
placeholder="Wait — run that back…"
style={{flex:1,padding:8,borderRadius:8,border:`1.5px solid ${T.line}`,fontFamily:"inherit"}} />
Send
)}
{tab==="glossary" && {busy?
decoding… :glossary.map((g,i)=>(
{g.term} — {g.def}
))}
}
{tab==="spark" && {busy?
writing questions… :sparks.map((s,i)=>(
setSparks(sp=>sp.map((x,j)=>j===i?{...x,open:!x.open}:x))}
style={{background:"#2E2018",border:`1.5px solid ${T.line}`,borderRadius:8,padding:10,marginBottom:8,cursor:"pointer",fontSize:13}}>
⚡ {s.q}
{s.open &&
{s.a}
}
{!s.open && s.a &&
tap to reveal
}
))}
}
{tab==="recap" && {busy?compressing… :recap}
}
);
}