scribin / src /Scribin.jsx
AIBRUH's picture
Initial Scribin' v2 — reading companion
1c64d9e
Raw
History Blame Contribute Delete
48.7 kB
// 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 && (
<div onClick={()=>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"}}>
<div onClick={e=>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)"}}>
<div style={{fontSize:10,letterSpacing:1.4,textTransform:"uppercase",color:S.cardMuted,marginBottom:14,display:"flex",alignItems:"center",gap:8}}>
<span style={{width:6,height:6,borderRadius:"50%",background:S.orange,display:"inline-block"}} />
bring something in
</div>
<div style={{fontSize:21,fontWeight:500,color:S.cardText,marginBottom:5}}>What should she read?</div>
<div style={{fontSize:13,color:S.cardMuted,marginBottom:20,lineHeight:1.55}}>Paste a link — she fetches and narrates, or takes you there with her.</div>
<div style={{display:"flex",gap:6,marginBottom:16}}>
{Object.keys(MODES).map(m=>(
<button key={m} onClick={()=>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}</button>
))}
</div>
<div style={{display:"flex",alignItems:"center",gap:8,background:S.ash2,border:`0.5px solid ${S.ash3}`,
borderRadius:12,padding:"11px 10px 11px 14px",marginBottom:12}}>
<input autoFocus value={urlInput} onChange={e=>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"}} />
</div>
<div style={{display:"flex",gap:8}}>
<button onClick={takeMeThere} style={{flex:1,padding:"11px 0",borderRadius:10,background:S.ash2,
border:`0.5px solid ${S.ash3}`,color:S.cardMuted,fontSize:13,fontWeight:500,cursor:"pointer",fontFamily:"inherit",
display:"flex",alignItems:"center",justifyContent:"center",gap:7}}>
<span></span> Take me there
</button>
<button onClick={geaux} style={{padding:"11px 20px",borderRadius:10,background:S.orange,border:"none",
color:"#FFF5EE",fontFamily:"inherit",cursor:"pointer",display:"flex",alignItems:"center",gap:10,whiteSpace:"nowrap"}}>
<span>
<span style={{display:"block",fontSize:14,fontWeight:600,letterSpacing:.2}}>Geaux</span>
<span style={{display:"block",fontSize:10,color:"rgba(255,245,238,.5)",marginTop:1}}>she goes · you stay</span>
</span>
<span style={{fontSize:18,opacity:.75}}></span>
</button>
</div>
<div style={{marginTop:12,padding:"11px 14px",background:S.ash2,borderRadius:10,
border:"0.5px solid rgba(74,140,92,.5)",fontSize:12,color:S.cardMuted,lineHeight:1.55,display:"flex",gap:10}}>
<span style={{flexShrink:0}}></span>
<span><b style={{color:S.greenText,fontWeight:500}}>Geaux</b> means she fetches it and reads out loud while you stay here. Her voice comes through the dock.</span>
</div>
</div>
</div>
);
const pct = sentences.length ? Math.round((cursor/sentences.length)*100) : 0;
// ── dock ──
if (docked) {
return (
<div style={{ position:"fixed", bottom:24, [dockSide]:24, zIndex:50, fontFamily:"Georgia, serif" }}>
<Overlay />
<input ref={fileRef} type="file" accept="application/pdf" style={{display:"none"}} onChange={e=>handleFile(e.target.files[0])} />
{/* Main pill */}
<div onClick={()=> 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" }}>
<div className="scribin-tile" style={{
width:46, height:46, borderRadius:12, flexShrink:0,
background:S.ash2,
animation:"scribinTilePulse 1.8s ease-in-out infinite",
display:"flex", alignItems:"center", justifyContent:"center" }}>
<style>{`
@keyframes scribinWave {
0%, 100% { opacity: .25; transform: scale(.94); }
50% { opacity: 1; transform: scale(1.1); }
}
@keyframes scribinWaveFar {
0%, 100% { opacity: 0; transform: scale(.9); }
45%, 60% { opacity: .5; transform: scale(1.14); }
}
@keyframes scribinHorn {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.045) translateX(.3px); }
}
@keyframes scribinTilePulse {
0%, 100% { box-shadow: 0 0 0 0 rgba(196,92,26,0); }
50% { box-shadow: 0 0 0 4px rgba(196,92,26,.12); }
}
@keyframes scribinNameShimmer {
0%, 100% { text-shadow: 0 0 0 rgba(196,92,26,0); }
50% { text-shadow: 0 0 14px rgba(196,92,26,.35); }
}
@media (prefers-reduced-motion: reduce) {
.scribin-wave, .scribin-horn, .scribin-tile, .scribin-name { animation: none !important; opacity: .85 !important; }
}
`}</style>
<svg viewBox="0 0 24 24" style={{width:26,height:26,stroke:S.orange,strokeWidth:1.8,fill:"none",strokeLinecap:"round",strokeLinejoin:"round",overflow:"visible"}}>
<path className="scribin-horn" d="M11 5 6 9H2v6h4l5 4V5z" fill="rgba(196,92,26,.18)"
style={{animation:"scribinHorn 1.8s ease-in-out infinite", transformOrigin:"7px 12px"}}/>
<path className="scribin-wave" d="M15.54 8.46a5 5 0 0 1 0 7.07"
style={{animation:"scribinWave 1.8s ease-in-out infinite", transformOrigin:"13px 12px"}}/>
<path className="scribin-wave" d="M19.07 4.93a10 10 0 0 1 0 14.14"
style={{animation:"scribinWave 1.8s ease-in-out .3s infinite", transformOrigin:"13px 12px"}}/>
<path className="scribin-wave" d="M22.2 2.2a14 14 0 0 1 0 19.6" strokeWidth="1.4"
style={{animation:"scribinWaveFar 1.8s ease-in-out .6s infinite", transformOrigin:"13px 12px"}}/>
</svg>
</div>
<div style={{minWidth:0}}>
<div className="scribin-name" style={{
fontFamily:"Georgia, 'Times New Roman', serif", fontStyle:"italic",
fontWeight:700, fontSize:17, letterSpacing:.4, color:S.cardText, lineHeight:1,
animation:"scribinNameShimmer 1.8s ease-in-out .3s infinite"}}>
Scribin<span style={{color:S.orange, fontSize:19, fontStyle:"normal", verticalAlign:"-1px"}}>&rsquo;</span>
</div>
<div style={{fontSize:11, color:S.cardMuted, marginTop:2, letterSpacing:.3}}>
{loading || (geauxing ? "reading aloud…" : docName ? `${pct}% through` : "pdf · url · youtube")}
</div>
</div>
{geauxing ? (
<div style={{marginLeft:"auto",fontSize:10,letterSpacing:.5,
background:"rgba(74,140,92,.18)",color:S.greenText,
border:"0.5px solid rgba(74,140,92,.35)",
borderRadius:6,padding:"2px 8px",whiteSpace:"nowrap"}}>Geaux</div>
) : docName && (
<div style={{marginLeft:"auto", width:32, height:32, borderRadius:8,
background:"rgba(196,92,26,.15)", border:"1px solid rgba(196,92,26,.3)",
display:"flex", alignItems:"center", justifyContent:"center",
fontSize:14, color:S.orange}}></div>
)}
</div>
{/* Enter Studio — admin panel trigger */}
<div onClick={()=>setStudio(s=>!s)}
style={{display:"flex", alignItems:"center", justifyContent:"center", gap:6,
marginTop:7, cursor:"pointer", userSelect:"none"}}>
<div style={{height:"1px", flex:1, background:"rgba(196,92,26,.25)"}} />
<span style={{fontSize:10, color:S.cardMuted, letterSpacing:1.2, textTransform:"uppercase"}}>
{studio ? "close studio" : "enter studio"}
</span>
<div style={{height:"1px", flex:1, background:"rgba(196,92,26,.25)"}} />
</div>
{/* HuggingFace Voice Agent */}
<div onClick={()=>{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"}}>
<div style={{height:"1px", flex:1, background:"rgba(196,92,26,.15)"}} />
<span style={{fontSize:10, color:"rgba(196,92,26,.7)", letterSpacing:1.2, textTransform:"uppercase"}}>
hf scout {hfOpen ? "▴" : "▾"}
</span>
<div style={{height:"1px", flex:1, background:"rgba(196,92,26,.15)"}} />
</div>
{hfOpen && (
<div style={{marginTop:8,background:S.ash2,border:"1px solid rgba(196,92,26,.22)",borderRadius:14,padding:"14px 16px",
minWidth:280,boxShadow:"0 16px 48px rgba(0,0,0,.55)",color:S.cardText}}>
<div style={{fontWeight:600,fontSize:12,letterSpacing:.8,textTransform:"uppercase",marginBottom:8,color:S.orange,display:"flex",alignItems:"center",gap:6}}>
hf scout
</div>
<div style={{fontSize:11,color:S.cardMuted,marginBottom:10}}>
She browses Hugging Face and reads it to you.
</div>
{/* Quick prompts */}
<div style={{display:"flex",flexDirection:"column",gap:5,marginBottom:10}}>
{QUICK_PROMPTS.map((q,i)=>(
<button key={i} onClick={()=>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]} →
</button>
))}
</div>
{/* Log */}
<div style={{maxHeight:200,overflowY:"auto",marginBottom:8}}>
{hfLog.map((m,i)=>(
<div key={i} style={{background:m.role==="user"?"rgba(255,255,255,.05)":"rgba(255,210,30,.07)",
border:"1px solid rgba(255,210,30,.15)",borderRadius:8,padding:"8px 10px",fontSize:12,marginBottom:5,color:m.role==="user"?"#C8D8F0":"#E8D5A0",lineHeight:1.5}}>
<b style={{color:m.role==="user"?"#7BA7D4":"#FFD21E"}}>{m.role==="user"?"You":"Scout"}</b> — {m.content}
</div>
))}
{hfBusy && <div style={{fontSize:11,fontStyle:"italic",color:"#FFD21E",padding:"4px 0"}}>browsing HuggingFace…</div>}
</div>
{/* Input */}
<div style={{display:"flex",gap:6}}>
<input value={hfInput} onChange={e=>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}} />
<button onClick={()=>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</button>
</div>
</div>
)}
{/* Studio panel */}
{studio && (
<div style={{marginTop:8,background:S.ash2,border:"1px solid rgba(196,92,26,.25)",borderRadius:14,padding:"16px 16px",
minWidth:240,boxShadow:"0 16px 48px rgba(0,0,0,.55)",color:S.cardText}}>
<div style={{fontWeight:500,fontSize:11,letterSpacing:1.2,textTransform:"uppercase",marginBottom:14,color:S.cardMuted}}>controls</div>
<div style={{fontSize:11,color:S.cardMuted,marginBottom:4,letterSpacing:.3}}>vibe</div>
<select value={vibe} onChange={e=>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=><option key={v}>{v}</option>)}
</select>
<div style={{fontSize:11,color:S.cardMuted,marginBottom:4,letterSpacing:.3}}>speed · {rate.toFixed(2)}×</div>
<input type="range" min="0.5" max="1.8" step="0.05" value={rate} onChange={e=>setRate(+e.target.value)}
style={{width:"100%",accentColor:S.orange,marginBottom:10}} />
<div style={{fontSize:12,marginBottom:4}}>Pitch · {pitch.toFixed(2)}</div>
<input type="range" min="0.6" max="1.2" step="0.02" value={pitch} onChange={e=>setPitch(+e.target.value)}
style={{width:"100%",accentColor:S.orange,marginBottom:10}} />
<div style={{fontSize:11,color:S.cardMuted,marginBottom:4,letterSpacing:.3}}>voice</div>
<select value={voiceURI||""} onChange={e=>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=><option key={v.voiceURI} value={v.voiceURI}>{v.name}</option>)}
</select>
<label style={{display:"flex",alignItems:"center",gap:8,fontSize:12,marginBottom:6,cursor:"pointer",color:S.cardText}}>
<input type="checkbox" checked={autoTune} onChange={e=>setAutoTune(e.target.checked)} style={{accentColor:S.orange}} />
Smart Tune
</label>
<label style={{display:"flex",alignItems:"center",gap:8,fontSize:12,marginBottom:12,cursor:"pointer",color:S.cardText}}>
<input type="checkbox" checked={companion} onChange={e=>setCompanion(e.target.checked)} style={{accentColor:S.orange}} />
Companion asides
</label>
<div style={{fontSize:11,color:S.cardMuted,marginBottom:6,letterSpacing:.3}}>position</div>
<div style={{display:"flex",gap:6,marginBottom:10}}>
{["left","right"].map(s=>(
<button key={s} onClick={()=>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}</button>
))}
</div>
{library.length>0 && (
<div style={{fontSize:10,color:S.cardMuted,marginTop:4}}>{library.length} doc{library.length!==1?"s":""} in memory</div>
)}
</div>
)}
</div>
);
}
const Btn = ({children,onClick,active,disabled}) => (
<button onClick={onClick} disabled={disabled} style={{
background:active?S.orange:"transparent", color:active?"#FFF5EE":T.ink,
border:`1.5px solid ${active?S.orange:T.line}`, borderRadius:8, padding:"7px 14px",
cursor:disabled?"wait":"pointer", fontFamily:"inherit", fontSize:13, fontWeight:600, opacity:disabled?.5:1 }}>{children}</button>
);
return (
<div style={{ minHeight:"100vh", background:T.paper, color:T.ink, fontFamily:"Georgia, 'Times New Roman', serif" }}>
<Overlay />
<input ref={fileRef} type="file" accept="application/pdf" style={{display:"none"}} onChange={e=>handleFile(e.target.files[0])} />
<header style={{ display:"flex", alignItems:"center", justifyContent:"space-between",
padding:"14px 28px", borderBottom:`1.5px solid ${T.line}`, position:"sticky", top:0, background:T.paper, zIndex:10, gap:12, flexWrap:"wrap" }}>
<div style={{display:"flex",alignItems:"baseline",gap:14}}>
<h1 style={{margin:0,fontSize:27,letterSpacing:.3,fontStyle:"italic",fontWeight:700}}>Scribin<span style={{color:S.orange,fontSize:30,fontStyle:"normal",verticalAlign:"-2px"}}>&rsquo;</span></h1>
<span style={{fontSize:12,color:"#B39A80",fontStyle:"italic",maxWidth:220,overflow:"hidden",textOverflow:"ellipsis",whiteSpace:"nowrap"}}>{docName || "no document"}</span>
</div>
<Btn active onClick={()=>setOverlay(true)}>+ Bring something in</Btn>
<div style={{display:"flex",gap:8}}>
<Btn onClick={()=>fileRef.current.click()}>📄 PDF</Btn>
<Btn onClick={()=>{stopSpeech();setDocked(true);}}>Dock ▾</Btn>
</div>
</header>
{loading && <div style={{padding:"6px 28px",fontSize:13,color:S.orange,fontStyle:"italic",borderBottom:`1px solid ${T.line}`}}>⏳ {loading}</div>}
{aside && <div style={{padding:"6px 28px",fontSize:13,color:T.gold,fontStyle:"italic",borderBottom:`1px solid ${T.line}`}}>💛 "{aside}"</div>}
<div style={{height:4,background:T.line}}>
<div style={{height:"100%",width:`${pct}%`,background:`linear-gradient(90deg, ${S.orange}, ${T.gold})`,transition:"width .4s"}} />
</div>
<div style={{display:"grid",gridTemplateColumns:"minmax(0,1fr) 360px",maxWidth:1280,margin:"0 auto"}}>
<main ref={scrollRef} style={{padding:"28px 36px",maxHeight:"calc(100vh - 150px)",overflowY:"auto",position:"relative"}}>
{sentences.length===0 ? (
<div style={{marginTop:40,maxWidth:640}}>
<div style={{textAlign:"center",color:"#B39A80",marginBottom:28}}>
<div style={{fontSize:54}}>📖</div>
<p style={{fontSize:18}}>Give me a PDF, an article link, or a YouTube video — I'll read it to you like a friend would.</p>
</div>
{library.length>0 && (
<div>
<div style={{fontSize:12,fontWeight:700,letterSpacing:1,textTransform:"uppercase",color:S.orange,marginBottom:10}}>📚 Things we've read together</div>
{library.map(l=>(
<div key={l.name} onClick={()=>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"}}>
<div>
<div style={{fontWeight:700,fontSize:14}}>{l.name}</div>
{l.note && <div style={{fontSize:12,color:"#B39A80",marginTop:2}}>{l.note.slice(0,90)}…</div>}
</div>
<div style={{fontSize:12,color:T.sage,whiteSpace:"nowrap",marginLeft:12}}>{Math.round((l.cur/l.total)*100)}% · resume ▶</div>
</div>
))}
</div>
)}
</div>
) : (
<p style={{fontSize:18,lineHeight:1.85,maxWidth:720}}>
{sentences.map((s,i)=>(
<span key={i} id={`sent-${i}`} onClick={()=>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<cursor?"#6E5844":T.ink),
boxShadow:i===cursor?"0 0 18px rgba(200,155,109,.25), inset 0 -2px 0 rgba(200,155,109,.7)":"none" }}>{s} </span>
))}
</p>
)}
</main>
<aside style={{borderLeft:`1.5px solid ${T.line}`,padding:20,maxHeight:"calc(100vh - 150px)",overflowY:"auto"}}>
<div style={{display:"flex",gap:8,marginBottom:14}}>
<Btn onClick={()=>jump(Math.max(0,cursor-3))}>⏪</Btn>
<button onClick={playing?stopSpeech:play} disabled={!sentences.length} style={{
flex:1,background:S.orange,color:"#FFF5EE",border:"none",borderRadius:8,
padding:"10px 0",fontSize:15,fontWeight:700,cursor:"pointer",fontFamily:"inherit",opacity:sentences.length?1:.4}}>
{playing?"❚❚ Pause":"▶ Read to me"}
</button>
<Btn onClick={()=>jump(Math.min(sentences.length-1,cursor+3))}>⏩</Btn>
</div>
<div style={{fontSize:12,color:"#B39A80",marginBottom:16,display:"flex",justifyContent:"space-between"}}>
<span>🔥 Streak: <b>{streak}</b></span><span>{pct}% · position saved</span>
</div>
<label style={{fontSize:12,fontWeight:700,letterSpacing:1,textTransform:"uppercase",color:S.orange}}>Voice (all women)</label>
<select value={voiceURI||""} onChange={e=>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=><option key={v.voiceURI} value={v.voiceURI}>{v.name}</option>)}
</select>
<label style={{fontSize:12,fontWeight:700,letterSpacing:1,textTransform:"uppercase",color:S.orange}}>Vibe</label>
<div style={{display:"grid",gridTemplateColumns:"1fr 1fr",gap:6,margin:"6px 0 4px"}}>
{Object.keys(VIBES).map(v=><Btn key={v} active={vibe===v} onClick={()=>applyVibe(v)}>{v}</Btn>)}
</div>
<div style={{fontSize:11,color:"#B39A80",marginBottom:12,fontStyle:"italic"}}>{VIBES[vibe].desc}</div>
<div style={{fontSize:13,marginBottom:4}}>Speed · {rate.toFixed(2)}×</div>
<input type="range" min="0.5" max="1.8" step="0.05" value={rate} onChange={e=>setRate(+e.target.value)} style={{width:"100%",accentColor:S.orange}} />
<div style={{fontSize:13,margin:"8px 0 4px"}}>Pitch · {pitch.toFixed(2)} <span style={{color:"#B39A80"}}>(lower = more mature)</span></div>
<input type="range" min="0.6" max="1.2" step="0.02" value={pitch} onChange={e=>setPitch(+e.target.value)} style={{width:"100%",accentColor:S.orange}} />
<label style={{display:"flex",alignItems:"center",gap:8,fontSize:13,margin:"12px 0 6px",cursor:"pointer"}}>
<input type="checkbox" checked={autoTune} onChange={e=>setAutoTune(e.target.checked)} style={{accentColor:T.gold}} />
🎚️ Smart Tune — slows for math, lifts for questions
</label>
<label style={{display:"flex",alignItems:"center",gap:8,fontSize:13,margin:"0 0 18px",cursor:"pointer"}}>
<input type="checkbox" checked={companion} onChange={e=>setCompanion(e.target.checked)} style={{accentColor:T.gold}} />
💛 Companion Mode — she reacts like a friend while reading
</label>
<label style={{fontSize:12,fontWeight:700,letterSpacing:1,textTransform:"uppercase",color:S.orange}}>Study modes</label>
<div style={{display:"grid",gridTemplateColumns:"1fr 1fr",gap:6,margin:"6px 0 14px"}}>
<Btn active={tab==="verse"} onClick={()=>setTab("verse")}>💬 Verse</Btn>
<Btn active={tab==="glossary"} onClick={buildGlossary} disabled={busy}>🔍 Decode</Btn>
<Btn active={tab==="spark"} onClick={buildSparks} disabled={busy}>⚡ Spark</Btn>
<Btn active={tab==="recap"} onClick={buildRecap} disabled={busy}>🧠 Recap</Btn>
</div>
{tab==="verse" && (
<div>
<div style={{fontSize:12,color:"#B39A80",marginBottom:8,fontStyle:"italic"}}>She remembers this doc and your past reads. She answers out loud.</div>
<div style={{maxHeight:220,overflowY:"auto",marginBottom:8}}>
{verseLog.map((m,i)=>(
<div key={i} style={{background:m.role==="user"?"#2E2018":"rgba(138,90,59,.18)",border:`1px solid ${T.line}`,borderRadius:8,padding:"8px 10px",fontSize:13,marginBottom:6}}>
<b style={{color:m.role==="user"?T.sage:T.plum}}>{m.role==="user"?"You":"Scribin'"}</b> — {m.content}
</div>
))}
{busy && <div style={{fontSize:12,fontStyle:"italic",color:S.orange}}>thinking…</div>}
</div>
<div style={{display:"flex",gap:6}}>
<input value={verseInput} onChange={e=>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"}} />
<Btn active onClick={sendVerse} disabled={busy}>Send</Btn>
</div>
</div>
)}
{tab==="glossary" && <div>{busy?<i style={{fontSize:13}}>decoding…</i>:glossary.map((g,i)=>(
<div key={i} style={{marginBottom:8,fontSize:13}}><b style={{color:S.orange}}>{g.term}</b> — {g.def}</div>))}</div>}
{tab==="spark" && <div>{busy?<i style={{fontSize:13}}>writing questions…</i>:sparks.map((s,i)=>(
<div key={i} onClick={()=>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}}>
<b>⚡ {s.q}</b>
{s.open && <div style={{marginTop:6,color:T.sage}}>{s.a}</div>}
{!s.open && s.a && <div style={{fontSize:11,color:"#aaa",marginTop:4}}>tap to reveal</div>}
</div>))}</div>}
{tab==="recap" && <div style={{fontSize:13,whiteSpace:"pre-wrap",lineHeight:1.6}}>{busy?<i>compressing…</i>:recap}</div>}
</aside>
</div>
</div>
);
}