/** DOM rendering. All element lookups and mutations live here. */ const $ = (id) => document.getElementById(id); export const el = { modeBadge: $('modeBadge'), connPill: $('connPill'), connLabel: $('connLabel'), latencyChip: $('latencyChip'), latencyValue: $('latencyValue'), reconnectBtn: $('reconnectBtn'), banner: $('configBanner'), bannerTitle: $('bannerTitle'), bannerText: $('bannerText'), textInput: $('textInput'), counter: $('counter'), counterNow: $('counterNow'), counterMax: $('counterMax'), speakBtn: $('speakBtn'), speakLabel: $('speakLabel'), stopBtn: $('stopBtn'), clearBtn: $('clearBtn'), pipeline: $('pipeline'), pipelineFill: $('pipelineFill'), live: $('live'), liveText: $('liveText'), liveDetail: $('liveDetail'), factState: $('factState'), factMode: $('factMode'), factTransport: $('factTransport'), factAddress: $('factAddress'), factLatency: $('factLatency'), factUptime: $('factUptime'), statusNote: $('statusNote'), audioList: $('audioList'), audioCount: $('audioCount'), audioNote: $('audioNote'), clearAudioBtn: $('clearAudioBtn'), historyList: $('historyList'), historyEmpty: $('historyEmpty'), historyCount: $('historyCount'), clearHistoryBtn: $('clearHistoryBtn'), toasts: $('toasts'), }; const CONNECTION_LABEL = { connected: 'Robot Connected', connecting: 'Connecting…', disconnected: 'Robot Disconnected', error: 'Connection Error', }; const PIPELINE_PROGRESS = { idle: 0, queued: 8, sending: 26, processing: 52, speaking: 80, completed: 100, failed: 100, cancelled: 100, }; const STEP_ORDER = ['sending', 'processing', 'speaking', 'completed']; // ------------------------------------------------------------------------- // header / connection // ------------------------------------------------------------------------- export function renderConnection(status) { const state = status.state || 'disconnected'; el.connPill.dataset.state = state; el.connLabel.textContent = CONNECTION_LABEL[state] || state; const latency = status.latencyMs; if (typeof latency === 'number' && state === 'connected') { el.latencyChip.hidden = false; el.latencyValue.textContent = `${latency < 10 ? latency.toFixed(1) : Math.round(latency)} ms`; } else { el.latencyChip.hidden = true; } } export function renderConfig(config) { if (!config) return; const mode = (config.mode || 'mock').toLowerCase(); el.modeBadge.textContent = mode === 'real' ? 'Live Robot' : 'Mock Mode'; el.modeBadge.dataset.mode = mode; const max = config.maxLength || 1000; el.counterMax.textContent = String(max); el.textInput.maxLength = max; const issues = config.issues || []; if (issues.length) { const worst = issues.find((i) => i.level === 'error') || issues[0]; el.banner.hidden = false; el.banner.dataset.level = worst.level; el.bannerTitle.textContent = worst.level === 'error' ? 'Configuration error' : 'Configuration warning'; el.bannerText.textContent = `${worst.message} (${worst.key} in ${config.envFile || '.env'})`; } else { el.banner.hidden = true; } } export function renderFacts(status) { const robot = status.robot || {}; const state = status.state || 'disconnected'; el.factState.textContent = CONNECTION_LABEL[state] || state; el.factState.dataset.tone = state === 'connected' ? 'ok' : state === 'connecting' ? 'warn' : 'error'; el.factMode.textContent = robot.mode === 'real' ? 'Live robot' : 'Simulation'; el.factTransport.textContent = (robot.transport || '–').toUpperCase(); el.factAddress.textContent = robot.address || '–'; el.factAddress.title = robot.address || ''; el.factLatency.textContent = typeof status.latencyMs === 'number' ? `${Math.round(status.latencyMs)} ms` : '–'; el.factUptime.textContent = typeof status.uptimeSeconds === 'number' ? formatDuration(status.uptimeSeconds) : '–'; if (status.error) { el.statusNote.hidden = false; el.statusNote.textContent = status.error; } else { el.statusNote.hidden = true; } } // ------------------------------------------------------------------------- // composer // ------------------------------------------------------------------------- export function renderCounter(length, max) { el.counterNow.textContent = String(length); const ratio = max ? length / max : 0; el.counter.dataset.level = ratio >= 1 ? 'over' : ratio > 0.85 ? 'warn' : 'ok'; } export function setControls({ canSpeak, busy, canStop }) { el.speakBtn.disabled = !canSpeak; el.speakBtn.dataset.busy = busy ? 'true' : 'false'; el.speakLabel.textContent = busy ? 'Speaking…' : 'Speak'; el.stopBtn.disabled = !canStop; } // ------------------------------------------------------------------------- // pipeline + live status // ------------------------------------------------------------------------- export function renderPipeline(stage) { const key = PIPELINE_PROGRESS[stage] === undefined ? 'idle' : stage; el.pipeline.dataset.stage = key; el.pipelineFill.style.inset = `0 ${100 - PIPELINE_PROGRESS[key]}% 0 0`; const activeIndex = STEP_ORDER.indexOf( key === 'queued' ? 'sending' : key === 'failed' || key === 'cancelled' ? 'speaking' : key, ); el.pipeline.querySelectorAll('li').forEach((li, index) => { if (key === 'idle') { li.removeAttribute('data-on'); return; } if (index < activeIndex) li.dataset.on = 'done'; else if (index === activeIndex) li.dataset.on = 'active'; else li.removeAttribute('data-on'); }); } export function renderLive(tone, text, detail = '') { el.live.dataset.tone = tone; el.liveText.textContent = text; el.liveDetail.textContent = detail || ''; } // ------------------------------------------------------------------------- // history // ------------------------------------------------------------------------- export function renderHistory(items, onPick, onReplay = () => {}) { el.historyCount.textContent = String(items.length); el.historyEmpty.hidden = items.length > 0; el.historyList.innerHTML = ''; for (const item of items) { const li = document.createElement('li'); li.className = 'hitem'; li.dataset.ok = item.success === null || item.success === undefined ? (isTerminal(item.stage) ? 'false' : 'pending') : String(Boolean(item.success)); li.title = 'Click to put this text back in the box'; li.tabIndex = 0; const bar = document.createElement('span'); bar.className = 'hitem__bar'; const body = document.createElement('div'); body.className = 'hitem__body'; const text = document.createElement('div'); text.className = 'hitem__text'; text.textContent = item.text; const meta = document.createElement('div'); meta.className = 'hitem__meta'; meta.append(spanOf(formatTime(item.at))); if (typeof item.ackLatencyMs === 'number') meta.append(spanOf(`${item.ackLatencyMs} ms`)); if (item.error) { const err = spanOf(item.error); err.className = 'err'; meta.append(err); } else if (!isTerminal(item.stage)) { meta.append(spanOf(item.stage)); } body.append(text, meta); li.append(bar, body); if (item.audioSaved) { const replay = document.createElement('button'); replay.className = 'hitem__replay'; replay.title = 'Play the saved audio instantly'; replay.setAttribute('aria-label', 'Replay this line'); replay.innerHTML = ''; replay.addEventListener('click', (event) => { event.stopPropagation(); // do not also load it into the box onReplay(item); }); li.append(replay); } const pick = () => onPick(item.text); li.addEventListener('click', pick); li.addEventListener('keydown', (event) => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); pick(); } }); el.historyList.append(li); } } function isTerminal(stage) { return ['completed', 'failed', 'cancelled'].includes(stage); } function spanOf(text) { const span = document.createElement('span'); span.textContent = text; return span; } // ------------------------------------------------------------------------- // toasts // ------------------------------------------------------------------------- export function toast(message, tone = 'info', ttl = 5000) { const node = document.createElement('div'); node.className = 'toast'; node.dataset.tone = tone; const dot = document.createElement('span'); dot.className = 'toast__dot'; const text = document.createElement('div'); text.textContent = message; node.append(dot, text); el.toasts.append(node); const remove = () => { node.classList.add('is-out'); setTimeout(() => node.remove(), 220); }; const timer = setTimeout(remove, ttl); node.addEventListener('click', () => { clearTimeout(timer); remove(); }); return node; } // ------------------------------------------------------------------------- // helpers // ------------------------------------------------------------------------- export function formatTime(epochSeconds) { if (!epochSeconds) return ''; return new Date(epochSeconds * 1000).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit', }); } export function formatDuration(seconds) { const total = Math.max(0, Math.round(seconds)); if (total < 60) return `${total}s`; const minutes = Math.floor(total / 60); if (minutes < 60) return `${minutes}m ${total % 60}s`; return `${Math.floor(minutes / 60)}h ${minutes % 60}m`; } // ------------------------------------------------------------------------- // saved audio // ------------------------------------------------------------------------- const PLAY_ICON = ''; const STOP_ICON = ''; const DOWNLOAD_ICON = '' + ''; export function renderAudio(items, stats, { fileUrl, onPlayState }) { el.audioCount.textContent = String(items.length); el.audioList.innerHTML = ''; if (!items.length) { el.audioNote.textContent = 'Nothing saved yet. Speak a line with the neural voice and its audio is ' + 'kept here as a .wav, so it replays instantly with no synthesis.'; return; } // Keep the note to one short line; the full path would overflow the panel, // so it goes in the tooltip instead. el.audioNote.textContent = `${items.length} clip(s) · ${(stats.seconds || 0).toFixed(0)}s · ` + `${((stats.bytes || 0) / 1048576).toFixed(1)} MB · audio_library/`; el.audioNote.title = stats.dir || ''; for (const item of items) { const li = document.createElement('li'); li.className = 'aitem'; const button = document.createElement('button'); button.className = 'aitem__play'; button.innerHTML = PLAY_ICON; button.title = 'Play in this browser'; // Played in the page, not through the backend: this is you auditioning a // saved clip, not the robot speaking. Keeping them separate means the // dashboard's Speaking state never lies. const audio = new Audio(fileUrl(item.id)); button.addEventListener('click', () => { if (!audio.paused) { audio.pause(); audio.currentTime = 0; return; } onPlayState(audio); audio.play().catch(() => {}); }); audio.addEventListener('play', () => { button.dataset.playing = 'true'; button.innerHTML = STOP_ICON; }); const reset = () => { button.dataset.playing = 'false'; button.innerHTML = PLAY_ICON; }; audio.addEventListener('pause', reset); audio.addEventListener('ended', reset); const body = document.createElement('div'); body.className = 'aitem__body'; const text = document.createElement('div'); text.className = 'aitem__text'; text.textContent = item.text; text.title = item.text; const meta = document.createElement('div'); meta.className = 'aitem__meta'; meta.textContent = `${(item.durationSeconds || 0).toFixed(1)}s · ${item.voice} · ${item.file}`; meta.title = item.file; body.append(text, meta); const download = document.createElement('a'); download.className = 'aitem__dl'; download.href = fileUrl(item.id); download.download = item.file; download.title = 'Download this .wav'; download.innerHTML = DOWNLOAD_ICON; li.append(button, body, download); el.audioList.append(li); } }