// 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=>( ))}
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"}} />
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)=>( ))}
{/* 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}} />
)} {/* Studio panel */} {studio && (
controls
vibe
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
position
{["left","right"].map(s=>( ))}
{library.length>0 && (
{library.length} doc{library.length!==1?"s":""} in memory
)}
)}
); } const Btn = ({children,onClick,active,disabled}) => ( ); 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} ))}

)}
); }