/** 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);
}
}