// AFTER — Coach screen with Web Speech API (voice in/out) const { useState: useStateCoach, useRef, useEffect: useEffectCoach } = React; const CC = { cream: '#fafaf8', black: '#0f0f0f', green: '#3d6b4f', grayLight: '#f0efed', gray: '#6b6b6b', border: '#e2e0db', red: '#dc2626', }; const ccStyles = { screen: { background: CC.cream, height: '100%', display: 'flex', flexDirection: 'column' }, header: { borderBottom: `1px solid ${CC.border}`, padding: '64px 20px 12px', display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }, headerTitle: { fontFamily: '"DM Serif Display", Georgia, serif', fontSize: 24, color: CC.black }, headerSub: { fontFamily: '"DM Sans", system-ui', fontSize: 14, color: CC.gray, marginTop: 4 }, speakerToggle: (on) => ({ width: 36, height: 36, borderRadius: '50%', border: `1px solid ${on ? CC.green : CC.border}`, background: on ? CC.green : 'transparent', color: on ? CC.cream : CC.gray, display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', flexShrink: 0, }), messages: { flex: 1, overflowY: 'auto', padding: '20px 16px', display: 'flex', flexDirection: 'column', gap: 12 }, bubbleRow: (isUser) => ({ display: 'flex', justifyContent: isUser ? 'flex-end' : 'flex-start' }), bubble: (isUser, speaking) => ({ maxWidth: '82%', borderRadius: 16, borderBottomRightRadius: isUser ? 4 : 16, borderBottomLeftRadius: isUser ? 16 : 4, padding: '12px 16px', background: isUser ? CC.green : CC.grayLight, fontFamily: '"DM Sans", system-ui', fontSize: 16, lineHeight: 1.5, color: isUser ? CC.cream : CC.black, boxShadow: speaking ? `0 0 0 2px ${CC.green}` : 'none', transition: 'box-shadow .2s', }), listeningBanner: { background: CC.green, color: CC.cream, padding: '8px 16px', fontFamily: '"DM Sans", system-ui', fontSize: 13, fontWeight: 500, textAlign: 'center', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8, }, pulseDot: { width: 8, height: 8, borderRadius: '50%', background: CC.cream, animation: 'coach-pulse 1s infinite', }, unsupportedBanner: { background: '#fef2f2', color: CC.red, padding: '8px 16px', fontFamily: '"DM Sans", system-ui', fontSize: 12, borderTop: `1px solid #fecaca`, textAlign: 'center', }, inputBar: { borderTop: `1px solid ${CC.border}`, background: CC.cream, padding: '12px 16px', display: 'flex', gap: 8, alignItems: 'flex-end', }, textarea: { flex: 1, borderRadius: 16, border: `1px solid ${CC.border}`, background: CC.cream, padding: '12px 16px', fontFamily: '"DM Sans", system-ui', fontSize: 16, color: CC.black, outline: 'none', resize: 'none', lineHeight: 1.5, maxHeight: 120, minHeight: 48, }, iconBtn: (active, disabled) => ({ width: 48, height: 48, borderRadius: '50%', background: active ? CC.red : CC.green, border: 'none', cursor: disabled ? 'not-allowed' : 'pointer', opacity: disabled ? .4 : 1, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0, transition: 'background .15s', }), }; // Add pulse keyframes if (typeof document !== 'undefined' && !document.getElementById('coach-anim')) { const s = document.createElement('style'); s.id = 'coach-anim'; s.textContent = `@keyframes coach-pulse { 0%, 100% { opacity: 1; transform: scale(1); } 50% { opacity: .4; transform: scale(1.4); } }`; document.head.appendChild(s); } const SEED_MESSAGES = [ { id: '1', role: 'assistant', content: "Morning, Sarah. How loud is the food noise right now, on a scale of one to ten?" }, { id: '2', role: 'user', content: "Maybe a 7? I keep thinking about the bagels in the kitchen." }, { id: '3', role: 'assistant', content: "That's loud, but it's not you. It's a signal. When did you eat last, and was there protein in it?" }, { id: '4', role: 'user', content: "Coffee at 7. Nothing else yet." }, { id: '5', role: 'assistant', content: "Then this is hunger wearing a costume. Eat a real breakfast first — eggs, yogurt, anything with protein — and we'll see if the bagel is still talking in twenty minutes." }, ]; function CoachScreen() { const [messages, setMessages] = useStateCoach(SEED_MESSAGES); const [input, setInput] = useStateCoach(''); const [listening, setListening] = useStateCoach(false); const [speakerOn, setSpeakerOn] = useStateCoach(true); const [speakingId, setSpeakingId] = useStateCoach(null); const [error, setError] = useStateCoach(null); const recognitionRef = useRef(null); const messagesRef = useRef(null); const SR = typeof window !== 'undefined' && (window.SpeechRecognition || window.webkitSpeechRecognition); const TTS = typeof window !== 'undefined' && window.speechSynthesis; const speechSupported = !!SR; const ttsSupported = !!TTS; // auto-scroll useEffectCoach(() => { if (messagesRef.current) messagesRef.current.scrollTop = messagesRef.current.scrollHeight; }, [messages]); // init SpeechRecognition useEffectCoach(() => { if (!SR) return; const rec = new SR(); rec.continuous = false; rec.interimResults = true; rec.lang = 'en-US'; let finalTranscript = ''; rec.onresult = (e) => { let interim = ''; finalTranscript = ''; for (let i = 0; i < e.results.length; i++) { const t = e.results[i][0].transcript; if (e.results[i].isFinal) finalTranscript += t; else interim += t; } setInput(finalTranscript || interim); }; rec.onerror = (e) => { setError(e.error === 'not-allowed' ? 'Microphone permission denied.' : `Voice error: ${e.error}`); setListening(false); }; rec.onend = () => { setListening(false); if (finalTranscript.trim()) { // auto-send when speech ends with content sendMessage(finalTranscript.trim()); } }; recognitionRef.current = rec; return () => { try { rec.stop(); } catch {} }; }, []); function speak(text, id) { if (!TTS || !speakerOn) return; TTS.cancel(); const u = new SpeechSynthesisUtterance(text); u.rate = 1.0; u.pitch = 1.0; u.volume = 1.0; // Prefer a calm female voice if available const voices = TTS.getVoices(); const preferred = voices.find(v => /samantha|female|allison|joanna|karen/i.test(v.name)) || voices.find(v => v.lang.startsWith('en')); if (preferred) u.voice = preferred; u.onstart = () => setSpeakingId(id); u.onend = () => setSpeakingId(null); u.onerror = () => setSpeakingId(null); TTS.speak(u); } function toggleListen() { if (!recognitionRef.current) return; setError(null); if (listening) { recognitionRef.current.stop(); setListening(false); } else { // Stop any ongoing TTS so the mic doesn't catch it if (TTS) TTS.cancel(); setSpeakingId(null); setInput(''); try { recognitionRef.current.start(); setListening(true); } catch (err) { setError('Could not start microphone.'); } } } function toggleSpeaker() { const next = !speakerOn; setSpeakerOn(next); if (!next && TTS) { TTS.cancel(); setSpeakingId(null); } } function sendMessage(textOverride) { const text = (textOverride ?? input).trim(); if (!text) return; const userId = Date.now().toString(); const replyId = (Date.now() + 1).toString(); const reply = generateReply(text); setMessages(m => [ ...m, { id: userId, role: 'user', content: text }, { id: replyId, role: 'assistant', content: reply }, ]); setInput(''); // Speak the reply setTimeout(() => speak(reply, replyId), 100); } return (