frontendt / src /components /Room.jsx
ayushsinghal1510's picture
Added coaching and hotel
252a04f
Raw
History Blame Contribute Delete
31.5 kB
import { useEffect, useRef, useState, useCallback, useMemo } from 'react'
import { useNavigate, useLocation } from 'react-router-dom'
import { buildCustoms, waitForIceGathering, PARTICIPANTS, RECORDING_CONFIG, WEBHOOK_URL, VX_SERVER } from '../lib/webrtc'
import { decodeSessionConfig } from '../lib/sessionConfig'
import { COMPANIES } from '../lib/companies'
const DEFAULT_KEY = import.meta.env.VITE_FLOW_API_KEY || ''
export default function Room({ sessionId, state }) {
const navigate = useNavigate()
const location = useLocation()
// Config resolution: state (dev shortcut) OR URL ?cfg= param (normal interviewee flow)
const config = useMemo(() => {
if (state?.name || state?.candidateName) return state
const encoded = new URLSearchParams(location.search).get('cfg')
return encoded ? decodeSessionConfig(encoded) : null
}, [state, location.search])
const name = config?.candidateName || config?.name || ''
const companyId = typeof config?.company === 'string' ? config.company : config?.company?.id
const companyObj = companyId ? (COMPANIES[companyId] || config?.company) : config?.company
const apiKey = config?.apiKey || DEFAULT_KEY
const server = config?.server || VX_SERVER
const voiceSettings = config?.voiceSettings || null
// Interviewee opened via URL → show join screen before connecting
const isUrlFlow = !state?.name && !state?.candidateName
const [started, setStarted] = useState(!isUrlFlow)
const watchUrl = `${window.location.origin}${window.location.pathname}#/watch/${sessionId}`
const pcRef = useRef(null)
const streamRef = useRef(null)
const audioCtxRef = useRef(null)
const userVideoRef = useRef(null)
const aiVideoRef = useRef(null)
const aiAudioRef = useRef(null)
const chatEndRef = useRef(null)
const [connState, setConnState] = useState('connecting')
const [aiState, setAiState] = useState('waiting')
const [userSpeaking, setUserSpeaking] = useState(false)
const [aiSpeaking, setAiSpeaking] = useState(false)
const [micMuted, setMicMuted] = useState(false)
const [hasUserVid, setHasUserVid] = useState(false)
const [hasAiVid, setHasAiVid] = useState(false)
const [elapsed, setElapsed] = useState(0)
const [chatMessages, setChatMessages] = useState([])
const [copied, setCopied] = useState(false)
// Timer — only runs once started
useEffect(() => {
if (!started) return
const t = setInterval(() => setElapsed(s => s + 1), 1000)
return () => clearInterval(t)
}, [started])
useEffect(() => {
chatEndRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [chatMessages])
// SSE — subscribe once we have a session
useEffect(() => {
if (!started) return
const es = new EventSource(`${WEBHOOK_URL}/events?session=${sessionId}`)
es.onmessage = e => {
try {
const data = JSON.parse(e.data)
const responses = data.responses || []
const msgs = []
// User transcript — non-streaming entry with out.user_input
const userEntry = responses.find(r => !r.streaming && r.out?.user_input)
if (userEntry) {
const text = userEntry.out.user_input
.replace(/<turn-visual-context>[\s\S]*?<\/turn-visual-context>/g, '')
.replace(/<non-verbal-elements>[\s\S]*?<\/non-verbal-elements>/g, '')
.trim()
if (text) msgs.push({ role: 'user', text })
}
// AI text — main LLM only (prev_llm is audio filler, not transcript)
const aiRaw = responses
.filter(r => r.streaming && r.chunk && r.node === 'llm')
.map(r => r.chunk)
.join('')
const aiText = aiRaw
.split(/<\|speak\|>/)
.map(seg => seg.replace(/<\|hangup\|>\S*/g, '').trim())
.filter(Boolean)
.join(' ')
.trim()
if (aiText) msgs.push({ role: 'ai', text: aiText })
if (msgs.length) setChatMessages(prev => [...prev, ...msgs])
} catch {}
}
return () => es.close()
}, [sessionId, started])
// VAD
const startVAD = useCallback((analyser, threshold, onSpeak, onSilence) => {
const buf = new Uint8Array(analyser.fftSize)
let speakFrames = 0, silentFrames = 0
const tick = () => {
analyser.getByteTimeDomainData(buf)
let sum = 0
for (let i = 0; i < buf.length; i++) { const v = (buf[i] - 128) / 128; sum += v * v }
const rms = Math.sqrt(sum / buf.length) * 100
if (rms > threshold) {
speakFrames++; silentFrames = 0
if (speakFrames > 3) onSpeak()
} else {
silentFrames++; speakFrames = 0
if (silentFrames > 8) onSilence()
}
requestAnimationFrame(tick)
}
tick()
}, [])
const setupUserVAD = useCallback((stream) => {
const ctx = audioCtxRef.current
const src = ctx.createMediaStreamSource(stream)
const an = ctx.createAnalyser(); an.fftSize = 512; an.smoothingTimeConstant = 0.7
src.connect(an)
startVAD(an, 12, () => setUserSpeaking(true), () => setUserSpeaking(false))
}, [startVAD])
const setupAIVAD = useCallback((stream) => {
if (!audioCtxRef.current) audioCtxRef.current = new (window.AudioContext || window.webkitAudioContext)()
const ctx = audioCtxRef.current
const src = ctx.createMediaStreamSource(stream)
const an = ctx.createAnalyser(); an.fftSize = 512; an.smoothingTimeConstant = 0.7
src.connect(an)
startVAD(an, 10,
() => { setAiSpeaking(true); setAiState('speaking') },
() => { setAiSpeaking(false); setAiState('listening') }
)
}, [startVAD])
// WebRTC bootstrap — only runs when started
useEffect(() => {
if (!started || !name || !companyObj) return
let cancelled = false
let reconnectAttempts = 0
let reconnectTimer = null
const MAX_RECONNECTS = 5
async function connect(stream) {
if (cancelled) return
let iceServers = []
try {
const r = await fetch(`${server}/rtc/ice-servers`, { signal: AbortSignal.timeout(3000) })
if (r.ok) { const d = await r.json(); iceServers = d.ice_servers || [] }
} catch {}
const pc = new RTCPeerConnection({ iceServers })
pcRef.current = pc
stream.getTracks().forEach(t => pc.addTrack(t, stream))
pc.ontrack = e => {
if (e.track.kind === 'audio') {
aiAudioRef.current.srcObject = e.streams[0]
setupAIVAD(e.streams[0])
} else if (e.track.kind === 'video') {
aiVideoRef.current.srcObject = e.streams[0]
setHasAiVid(true)
}
}
pc.onconnectionstatechange = () => {
const st = pc.connectionState
if (st === 'connected') { reconnectAttempts = 0; setConnState('connected') }
if (st === 'failed' || st === 'disconnected') {
pc.close()
if (!cancelled && reconnectAttempts < MAX_RECONNECTS) {
reconnectAttempts++
setConnState('connecting')
reconnectTimer = setTimeout(() => connect(stream), Math.min(1000 * reconnectAttempts, 5000))
} else if (!cancelled) {
setConnState('failed')
}
}
}
const offer = await pc.createOffer()
await pc.setLocalDescription(offer)
await waitForIceGathering(pc)
const customs = buildCustoms(companyId, name, voiceSettings)
let resp
try {
resp = await fetch(`${server}/rtc/offer/audio`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'api_key': apiKey },
body: JSON.stringify({
sdp: pc.localDescription.sdp,
type: pc.localDescription.type,
participants: PARTICIPANTS,
customs,
session_id: sessionId,
...RECORDING_CONFIG,
}),
})
} catch {
if (!cancelled && reconnectAttempts < MAX_RECONNECTS) {
reconnectAttempts++
setConnState('connecting')
reconnectTimer = setTimeout(() => connect(stream), Math.min(1000 * reconnectAttempts, 5000))
} else {
setConnState('failed')
}
return
}
if (!resp.ok) { setConnState('failed'); return }
const answer = await resp.json()
await pc.setRemoteDescription(new RTCSessionDescription(answer))
}
async function start() {
let stream
try {
stream = await navigator.mediaDevices.getUserMedia({ audio: true, video: true })
} catch {
try { stream = await navigator.mediaDevices.getUserMedia({ audio: true, video: false }) }
catch { setConnState('failed'); return }
}
if (cancelled) { stream.getTracks().forEach(t => t.stop()); return }
streamRef.current = stream
if (stream.getVideoTracks().length) {
userVideoRef.current.srcObject = stream
setHasUserVid(true)
}
audioCtxRef.current = new (window.AudioContext || window.webkitAudioContext)()
setupUserVAD(stream)
connect(stream)
}
start()
return () => {
cancelled = true
clearTimeout(reconnectTimer)
cleanup()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [started])
function cleanup() {
streamRef.current?.getTracks().forEach(t => t.stop())
pcRef.current?.close()
pcRef.current = null
audioCtxRef.current?.close()
audioCtxRef.current = null
}
function toggleMic() {
if (!streamRef.current) return
const next = !micMuted
setMicMuted(next)
streamRef.current.getAudioTracks().forEach(t => { t.enabled = !next })
if (next) setUserSpeaking(false)
}
function leave() {
cleanup()
navigate('/')
}
function copyLink() {
navigator.clipboard.writeText(watchUrl).then(() => {
setCopied(true)
setTimeout(() => setCopied(false), 2000)
})
}
const fmtTime = s => {
const m = Math.floor(s / 60).toString().padStart(2, '0')
const ss = (s % 60).toString().padStart(2, '0')
return `${m}:${ss}`
}
// ── Invalid link ─────────────────────────────────────────────────────────────
if (!config || !companyObj || !name) {
return (
<div style={j.page}>
<div style={j.inner}>
<p style={j.errorTitle}>Invalid interview link.</p>
<button style={j.backBtn} onClick={() => navigate('/')}>← home</button>
</div>
</div>
)
}
// ── Interviewee join screen ───────────────────────────────────────────────────
if (!started) {
return (
<div style={j.page}>
<div style={{ ...j.glow, background: `radial-gradient(ellipse 60% 50% at 30% 50%, ${companyObj.accent}08 0%, transparent 70%)` }} />
<div style={j.inner}>
<div style={{ ...j.iconBox, background: `${companyObj.accent}18`, color: companyObj.accent }}>
{companyObj.icon}
</div>
<p style={{ ...j.pill, borderColor: `${companyObj.accent}30`, color: companyObj.accent }}>
Mock interview
</p>
<h1 style={j.title}>
Ready when<br />
<span style={{ color: companyObj.accent }}>you are.</span>
</h1>
<div style={j.metaRow}>
<div style={j.metaItem}>
<span style={j.metaLabel}>Company</span>
<span style={j.metaValue}>{companyObj.name}</span>
</div>
<div style={j.metaDivider} />
<div style={j.metaItem}>
<span style={j.metaLabel}>Candidate</span>
<span style={j.metaValue}>{name}</span>
</div>
</div>
<p style={j.hint}>
The AI interviewer will open the session. This is a practice run — speak naturally and take your time.
Your microphone and camera will be requested.
</p>
<button
style={{ ...j.beginBtn, background: companyObj.accent }}
onClick={() => setStarted(true)}
>
Begin interview →
</button>
<p style={j.fine}>Everything stays within this session.</p>
</div>
</div>
)
}
// ── Interview room ────────────────────────────────────────────────────────────
const statusLabel = { connecting: 'Connecting…', connected: 'Connected', failed: 'Connection lost' }
const aiStateLabel = { waiting: 'Waiting', listening: 'Listening', thinking: 'Thinking…', speaking: 'Speaking' }
return (
<div style={s.root}>
{/* topbar */}
<header style={s.topbar}>
<div style={s.topLeft}>
<span style={s.sessionLabel}>Mock Interview</span>
<span style={{ ...s.companyChip, color: companyObj.accent, borderColor: `${companyObj.accent}40` }}>
{companyObj.name}
</span>
</div>
<div style={s.topRight}>
<span style={s.timer}>{fmtTime(elapsed)}</span>
<span style={{ ...s.connBadge, ...(connState === 'connected' ? s.connGreen : connState === 'failed' ? s.connRed : s.connYellow) }}>
{connState}
</span>
</div>
</header>
{/* body: stage + chat */}
<div style={s.body}>
{/* stage */}
<main style={s.stage}>
<div style={s.stageGlow1} />
<div style={s.stageGlow2} />
{/* user card */}
<div style={s.participantCol}>
<div style={{ ...s.avatarWrap, ...(hasUserVid ? s.videoWrap : {}) }}>
{userSpeaking && !micMuted && !hasUserVid && (
<>
<div style={{ ...s.ring, borderColor: 'var(--user-ring)', animation: 'pulse-ring 1.4s ease-out infinite' }} />
<div style={{ ...s.ringOuter, borderColor: 'var(--user-ring)', animation: 'pulse-ring 1.4s ease-out .35s infinite', opacity: .4 }} />
</>
)}
<div style={{ ...s.avatar, ...(hasUserVid ? s.videoAvatar : {}), borderColor: userSpeaking && !micMuted ? 'var(--user-ring)' : 'var(--border2)' }}>
<video ref={userVideoRef} autoPlay playsInline muted style={{ ...s.selfVideo, display: hasUserVid ? 'block' : 'none' }} />
{!hasUserVid && (userSpeaking && !micMuted
? <MicBars color="var(--user-ring)" />
: <span style={s.avatarIcon}>👤</span>
)}
</div>
</div>
<span style={s.participantName}>{name}</span>
<span style={s.participantRole}>candidate</span>
</div>
<div style={s.divider} />
{/* AI card */}
<div style={s.participantCol}>
<div style={{ ...s.avatarWrap, ...(hasAiVid ? s.videoWrap : {}) }}>
{aiSpeaking && !hasAiVid && (
<>
<div style={{ ...s.ring, borderColor: 'var(--ai-ring)', animation: 'pulse-ring 1.4s ease-out infinite' }} />
<div style={{ ...s.ringOuter, borderColor: 'var(--ai-ring)', animation: 'pulse-ring 1.4s ease-out .35s infinite', opacity: .4 }} />
</>
)}
<div style={{ ...s.avatar, ...(hasAiVid ? s.videoAvatar : {}), borderColor: aiSpeaking ? 'var(--ai-ring)' : 'var(--border2)' }}>
<video ref={aiVideoRef} autoPlay playsInline muted style={{ ...s.aiVideo, display: hasAiVid ? 'block' : 'none' }} />
{!hasAiVid && (aiSpeaking
? <WaveBars color="var(--ai-ring)" />
: <span style={s.avatarIcon}></span>
)}
</div>
</div>
<span style={s.participantName}>{companyObj.name} Interviewer</span>
<span style={s.participantRole}>AI</span>
</div>
</main>
{/* live chat panel */}
<aside style={s.chatPanel}>
<div style={s.observerBar}>
<div style={s.observerBarLeft}>
<span style={s.observerLabel}>Observer link</span>
<span style={s.observerUrl}>{watchUrl}</span>
</div>
<button
style={{ ...s.copyBtn, ...(copied ? s.copyBtnDone : {}) }}
onClick={copyLink}
>
{copied ? 'Copied!' : 'Copy'}
</button>
</div>
<div style={s.chatHeader}>
<span style={s.chatHeaderLabel}>Live Transcript</span>
{chatMessages.length > 0 && (
<span style={s.chatCount}>{chatMessages.length}</span>
)}
</div>
<div style={s.chatScroll}>
{chatMessages.length === 0
? <p style={s.chatEmpty}>Conversation will appear here…</p>
: chatMessages.map((msg, i) => (
<div key={i} style={{ ...s.chatMsg, ...(msg.role === 'user' ? s.chatMsgUser : s.chatMsgAi) }}>
<span style={{ ...s.chatRole, color: msg.role === 'user' ? 'var(--user-ring)' : 'var(--ai-ring)' }}>
{msg.role === 'user' ? name : 'Interviewer'}
</span>
<div style={{ ...s.chatBubble, ...(msg.role === 'user' ? s.chatBubbleUser : s.chatBubbleAi) }}>
{msg.text}
</div>
</div>
))
}
<div ref={chatEndRef} />
</div>
</aside>
</div>
{/* status row */}
<div style={s.statusRow}>
<div style={{ ...s.dot, background: connState === 'connected' ? 'var(--accent)' : connState === 'failed' ? 'var(--danger)' : '#f0c060', animation: connState === 'connecting' ? 'blink .8s step-end infinite' : 'none' }} />
<span style={s.statusText}>{statusLabel[connState]}</span>
</div>
{/* controls */}
<footer style={s.controls}>
<button style={{ ...s.ctrlBtn, ...(micMuted ? s.ctrlMuted : {}) }} onClick={toggleMic} title={micMuted ? 'Unmute' : 'Mute'}>
{micMuted ? <MicOffIcon /> : <MicIcon />}
</button>
<div style={{ ...s.aiChip, ...(aiState === 'speaking' ? s.aiChipSpeak : aiState === 'thinking' ? s.aiChipThink : aiState === 'listening' ? s.aiChipListen : {}) }}>
<div style={{ ...s.aiChipDot, background: aiState === 'speaking' ? 'var(--ai-ring)' : aiState === 'thinking' ? '#f0c060' : aiState === 'listening' ? 'var(--accent)' : 'var(--muted)', animation: aiState === 'thinking' ? 'blink .5s step-end infinite' : 'none' }} />
<span>{aiStateLabel[aiState]}</span>
</div>
<button style={{ ...s.ctrlBtn, ...s.ctrlLeave }} onClick={leave} title="Leave">
<LeaveIcon />
</button>
</footer>
<audio ref={aiAudioRef} autoPlay />
</div>
)
}
// ── Sub-components ────────────────────────────────────────────────────────────
function WaveBars({ color }) {
const heights = [14, 28, 20, 34, 18]
return (
<div style={{ display: 'flex', alignItems: 'flex-end', gap: 3, height: 36 }}>
{heights.map((h, i) => (
<div key={i} style={{ width: 4, minHeight: 4, borderRadius: 2, background: color, animation: `wave-bounce 0.8s ease-in-out ${i * 0.1}s infinite`, '--h': `${h}px` }} />
))}
</div>
)
}
function MicBars({ color }) {
const heights = [12, 26, 18, 30, 14]
return (
<div style={{ display: 'flex', alignItems: 'flex-end', gap: 3, height: 36 }}>
{heights.map((h, i) => (
<div key={i} style={{ width: 4, minHeight: 4, borderRadius: 2, background: color, animation: `wave-bounce 0.6s ease-in-out ${i * 0.08}s infinite`, '--h': `${h}px` }} />
))}
</div>
)
}
function MicIcon() {
return (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 2a3 3 0 0 1 3 3v7a3 3 0 0 1-6 0V5a3 3 0 0 1 3-3z"/>
<path d="M19 10v2a7 7 0 0 1-14 0v-2"/>
<line x1="12" y1="19" x2="12" y2="22"/>
<line x1="8" y1="22" x2="16" y2="22"/>
</svg>
)
}
function MicOffIcon() {
return (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<line x1="1" y1="1" x2="23" y2="23"/>
<path d="M9 9v3a3 3 0 0 0 5.12 2.12"/>
<path d="M15 9.34V5a3 3 0 0 0-5.94-.6"/>
<path d="M17 16.95A7 7 0 0 1 5 12v-2m14 0v2a7 7 0 0 1-.11 1.23"/>
<line x1="12" y1="19" x2="12" y2="22"/>
<line x1="8" y1="22" x2="16" y2="22"/>
</svg>
)
}
function LeaveIcon() {
return (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/>
<polyline points="16 17 21 12 16 7"/>
<line x1="21" y1="12" x2="9" y2="12"/>
</svg>
)
}
// ── Styles ────────────────────────────────────────────────────────────────────
// Join screen styles
const j = {
page: {
minHeight: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '48px 20px',
position: 'relative',
overflow: 'hidden',
},
glow: { position: 'fixed', inset: 0, pointerEvents: 'none' },
inner: {
width: '100%',
maxWidth: 420,
display: 'flex',
flexDirection: 'column',
position: 'relative',
zIndex: 1,
},
iconBox: {
width: 56,
height: 56,
borderRadius: 14,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: 24,
marginBottom: 20,
},
pill: {
display: 'inline-block',
alignSelf: 'flex-start',
fontFamily: 'var(--mono)',
fontSize: 9,
letterSpacing: '.2em',
textTransform: 'uppercase',
border: '1px solid',
padding: '4px 10px',
borderRadius: 20,
marginBottom: 20,
},
title: {
fontSize: 'clamp(32px, 6vw, 50px)',
fontWeight: 800,
lineHeight: .95,
letterSpacing: '-.03em',
marginBottom: 28,
},
metaRow: {
display: 'flex',
alignItems: 'stretch',
gap: 0,
background: 'var(--surface)',
border: '1px solid var(--border2)',
borderRadius: 10,
overflow: 'hidden',
marginBottom: 28,
},
metaItem: {
display: 'flex',
flexDirection: 'column',
gap: 4,
padding: '14px 18px',
flex: 1,
},
metaDivider: {
width: 1,
background: 'var(--border2)',
flexShrink: 0,
},
metaLabel: {
fontFamily: 'var(--mono)',
fontSize: 8,
letterSpacing: '.2em',
textTransform: 'uppercase',
color: 'var(--muted)',
},
metaValue: {
fontFamily: 'var(--sans)',
fontSize: 14,
fontWeight: 700,
color: 'var(--text)',
},
hint: {
fontFamily: 'var(--mono)',
fontSize: 11,
color: 'var(--muted)',
lineHeight: 1.7,
letterSpacing: '.04em',
marginBottom: 32,
},
beginBtn: {
color: '#0a0a0a',
border: 'none',
fontFamily: 'var(--sans)',
fontSize: 15,
fontWeight: 700,
letterSpacing: '.06em',
padding: '18px 28px',
borderRadius: 8,
cursor: 'pointer',
marginBottom: 16,
},
fine: {
fontFamily: 'var(--mono)',
fontSize: 10,
color: 'var(--muted)',
letterSpacing: '.1em',
textAlign: 'center',
},
errorTitle: {
fontFamily: 'var(--mono)',
fontSize: 13,
color: 'var(--muted)',
marginBottom: 16,
},
backBtn: {
fontFamily: 'var(--mono)',
fontSize: 11,
color: 'var(--muted)',
background: 'none',
border: 'none',
cursor: 'pointer',
letterSpacing: '.08em',
padding: 0,
},
}
// Room styles
const s = {
root: { height: '100vh', display: 'flex', flexDirection: 'column', overflow: 'hidden' },
topbar: {
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '0 28px', height: 56, borderBottom: '1px solid var(--border)', flexShrink: 0,
},
topLeft: { display: 'flex', alignItems: 'center', gap: 12 },
topRight: { display: 'flex', alignItems: 'center', gap: 12 },
sessionLabel: { fontFamily: 'var(--mono)', fontSize: 11, letterSpacing: '.18em', color: 'var(--muted)', textTransform: 'uppercase' },
companyChip: { fontFamily: 'var(--mono)', fontSize: 10, letterSpacing: '.14em', textTransform: 'uppercase', padding: '3px 9px', borderRadius: 20, border: '1px solid' },
timer: { fontFamily: 'var(--mono)', fontSize: 12, color: 'var(--muted)', letterSpacing: '.1em' },
connBadge: { fontFamily: 'var(--mono)', fontSize: 10, letterSpacing: '.12em', textTransform: 'uppercase', padding: '3px 9px', borderRadius: 20, border: '1px solid var(--border2)', color: 'var(--muted)' },
connGreen: { color: 'var(--accent)', borderColor: 'var(--accent)' },
connRed: { color: 'var(--danger)', borderColor: 'var(--danger)' },
connYellow:{ color: '#f0c060', borderColor: '#f0c06040' },
body: { flex: 1, display: 'flex', overflow: 'hidden' },
stage: {
flex: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: 48,
position: 'relative',
overflow: 'hidden',
},
stageGlow1: { position: 'absolute', inset: 0, background: 'radial-gradient(ellipse 50% 40% at 30% 60%, rgba(200,240,96,.04) 0%, transparent 70%)', pointerEvents: 'none' },
stageGlow2: { position: 'absolute', inset: 0, background: 'radial-gradient(ellipse 40% 40% at 70% 40%, rgba(96,160,240,.03) 0%, transparent 70%)', pointerEvents: 'none' },
participantCol: { display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 12, position: 'relative', zIndex: 1 },
avatarWrap: { position: 'relative', width: 120, height: 120 },
videoWrap: { width: 160, height: 120 },
ring: { position: 'absolute', inset: -8, borderRadius: '50%', border: '2px solid', opacity: .6 },
ringOuter: { position: 'absolute', inset: -16, borderRadius: '50%', border: '1px solid' },
avatar: {
width: '100%', height: '100%',
borderRadius: '50%',
border: '2px solid',
background: 'var(--surface)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
overflow: 'hidden',
transition: 'border-color .2s',
},
videoAvatar: { borderRadius: 12 },
avatarIcon: { fontSize: 32 },
selfVideo: { width: '100%', height: '100%', objectFit: 'cover' },
aiVideo: { width: '100%', height: '100%', objectFit: 'cover' },
participantName: { fontFamily: 'var(--sans)', fontSize: 13, fontWeight: 600, color: 'var(--text)' },
participantRole: { fontFamily: 'var(--mono)', fontSize: 9, letterSpacing: '.2em', textTransform: 'uppercase', color: 'var(--muted)' },
divider: { width: 1, height: 80, background: 'var(--border)' },
chatPanel: {
width: 300,
flexShrink: 0,
borderLeft: '1px solid var(--border)',
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
background: 'var(--surface)',
},
observerBar: {
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '10px 14px',
borderBottom: '1px solid var(--border)',
gap: 8,
flexShrink: 0,
},
observerBarLeft: { display: 'flex', flexDirection: 'column', gap: 2, flex: 1, minWidth: 0 },
observerLabel: { fontFamily: 'var(--mono)', fontSize: 8, letterSpacing: '.18em', textTransform: 'uppercase', color: 'var(--muted)' },
observerUrl: { fontFamily: 'var(--mono)', fontSize: 9, color: 'var(--muted)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' },
copyBtn: { fontFamily: 'var(--mono)', fontSize: 9, letterSpacing: '.1em', padding: '4px 10px', borderRadius: 5, border: '1px solid var(--border2)', background: 'transparent', color: 'var(--text)', cursor: 'pointer', flexShrink: 0, transition: 'border-color .15s, color .15s' },
copyBtnDone: { color: 'var(--accent)', borderColor: 'var(--accent)' },
chatHeader: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '10px 14px', borderBottom: '1px solid var(--border)', flexShrink: 0 },
chatHeaderLabel: { fontFamily: 'var(--mono)', fontSize: 9, letterSpacing: '.18em', textTransform: 'uppercase', color: 'var(--muted)' },
chatCount: { fontFamily: 'var(--mono)', fontSize: 9, color: 'var(--muted)', background: 'var(--border2)', padding: '1px 7px', borderRadius: 10 },
chatScroll: { flex: 1, overflowY: 'auto', padding: '14px', display: 'flex', flexDirection: 'column', gap: 12 },
chatEmpty: { fontFamily: 'var(--mono)', fontSize: 10, color: 'var(--muted)', letterSpacing: '.08em', textAlign: 'center', paddingTop: 24 },
chatMsg: { display: 'flex', flexDirection: 'column', gap: 4 },
chatMsgUser: { alignItems: 'flex-end' },
chatMsgAi: { alignItems: 'flex-start' },
chatRole: { fontFamily: 'var(--mono)', fontSize: 8, letterSpacing: '.18em', textTransform: 'uppercase' },
chatBubble: { fontSize: 12, lineHeight: 1.6, padding: '8px 11px', borderRadius: 10, wordBreak: 'break-word', maxWidth: '90%' },
chatBubbleUser: { background: 'rgba(200,240,96,.07)', border: '1px solid rgba(200,240,96,.15)', color: 'var(--text)', borderBottomRightRadius: 3 },
chatBubbleAi: { background: 'var(--bg)', border: '1px solid var(--border2)', color: 'var(--text)', borderBottomLeftRadius: 3 },
statusRow: { display: 'flex', alignItems: 'center', gap: 8, padding: '6px 28px', borderTop: '1px solid var(--border)', flexShrink: 0, background: 'var(--surface)' },
dot: { width: 6, height: 6, borderRadius: '50%', flexShrink: 0 },
statusText: { fontFamily: 'var(--mono)', fontSize: 10, letterSpacing: '.1em', color: 'var(--muted)' },
controls: {
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 20,
padding: '14px 28px', borderTop: '1px solid var(--border)', flexShrink: 0,
},
ctrlBtn: { width: 44, height: 44, borderRadius: '50%', border: '1px solid var(--border2)', background: 'var(--surface)', color: 'var(--text)', display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', transition: 'border-color .15s, background .15s' },
ctrlMuted: { background: 'var(--danger)', borderColor: 'var(--danger)', color: '#fff' },
ctrlLeave: { background: 'rgba(255,80,80,.1)', borderColor: 'rgba(255,80,80,.3)', color: 'var(--danger)' },
aiChip: { display: 'flex', alignItems: 'center', gap: 7, fontFamily: 'var(--mono)', fontSize: 10, letterSpacing: '.12em', textTransform: 'uppercase', color: 'var(--muted)', padding: '8px 14px', borderRadius: 20, border: '1px solid var(--border2)', background: 'var(--surface)', transition: 'border-color .2s, color .2s' },
aiChipDot: { width: 6, height: 6, borderRadius: '50%', flexShrink: 0, transition: 'background .2s' },
aiChipSpeak: { color: 'var(--ai-ring)', borderColor: 'var(--ai-ring)' },
aiChipThink: { color: '#f0c060', borderColor: '#f0c06040' },
aiChipListen: { color: 'var(--accent)', borderColor: 'rgba(200,240,96,.3)' },
}