| |
| |
| |
| |
|
|
| import React, { useState, useRef, useEffect, useCallback } from "react"; |
|
|
| |
| |
| 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)", |
| }; |
|
|
| |
| 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; |
| }; |
|
|
| |
| |
| 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); |
|
|
| |
| 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; }; |
| }, []); |
|
|
| |
| 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.`); |
| } |
| }; |
|
|
| |
| 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); |
| }; |
|
|
| |
| 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); |
| }; |
|
|
| |
| 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); }; |
|
|
| |
| 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); |
|
|
| |
| 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); |
| }; |
|
|
| |
| 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; |
|
|
| |
| 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"}}>’</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"}}>’</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> |
| ); |
| } |