fix(ui): simplify Vocero controls and correct session feedback

This commit is contained in:
2026-10-05 11:30:47 -03:00
parent cdbff5cb0e
commit a149f4ef8b
3 changed files with 174 additions and 476 deletions
+9 -46
View File
@@ -126,15 +126,6 @@
</div> </div>
<div id="app"> <div id="app">
<header class="topbar"> <header class="topbar">
<!-- Hamburger (theme=white only): icon button 3 horizontal lines, sits left of brand cluster, top-left corner, square icon, dark text, no visible borders.
Mirrors the 3-line menu icon in the screenshot header. -->
<button id="menu-btn" class="icon-btn menu-btn" type="button" title="Menú" aria-label="Abrir menú" hidden data-theme-only="white">
<svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
<line x1="3" y1="6" x2="21" y2="6"/>
<line x1="3" y1="12" x2="21" y2="12"/>
<line x1="3" y1="18" x2="21" y2="18"/>
</svg>
</button>
<div class="brand"> <div class="brand">
<div class="ident"> <div class="ident">
<div class="ident-head"> <div class="ident-head">
@@ -147,7 +138,7 @@
<!-- User avatar circle (theme=white only): grid item next to the brand, not <!-- User avatar circle (theme=white only): grid item next to the brand, not
absolute-positioned → eliminates overlap with title/subtitle entirely. absolute-positioned → eliminates overlap with title/subtitle entirely.
Same element id so main.js syncVoceroHeaderUser() still fills the text. --> Same element id so main.js syncVoceroHeaderUser() still fills the text. -->
<div id="ident-user-name" class="ident-user-name" hidden data-theme-only="white" aria-hidden="true">AV</div> <button id="ident-user-name" class="ident-user-name" type="button" hidden disabled data-theme-only="white" aria-label="Ver datos del usuario" aria-haspopup="dialog" aria-controls="user-profile-dialog"></button>
<!-- Left sidebar (reports) toggle: always visible, pinned left side of the <!-- Left sidebar (reports) toggle: always visible, pinned left side of the
topbar control cluster. Clicking slides in the offcanvas sidebar on topbar control cluster. Clicking slides in the offcanvas sidebar on
mobile; it's a no-op hover hint on desktop where sidebar is always mobile; it's a no-op hover hint on desktop where sidebar is always
@@ -279,9 +270,7 @@
<span class="w-bar wb-7" style="--bar-h: 0.3"></span> <span class="w-bar wb-7" style="--bar-h: 0.3"></span>
</div> </div>
<!-- Big circular mic button (theme=white). Click = start/stop voice session. <!-- Starts a voice session; becomes an indicator while connected. -->
Forwards all its clicks to the real #main-circle so the entire WS
pipeline still works. -->
<button id="white-mic-circle" <button id="white-mic-circle"
class="white-mic-circle state-idle" class="white-mic-circle state-idle"
type="button" type="button"
@@ -295,7 +284,6 @@
<line x1="12" y1="19" x2="12" y2="22"/> <line x1="12" y1="19" x2="12" y2="22"/>
<line x1="8" y1="22" x2="16" y2="22"/> <line x1="8" y1="22" x2="16" y2="22"/>
</svg> </svg>
<svg class="wmc-icon wmc-stop" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><rect x="6" y="6" width="12" height="12" rx="2"/></svg>
<span class="wmc-icon wmc-spinner" aria-hidden="true"></span> <span class="wmc-icon wmc-spinner" aria-hidden="true"></span>
</button> </button>
@@ -311,12 +299,14 @@
</div> </div>
<!-- Status caption below mic circle: listening, thinking, etc. --> <!-- Status caption below mic circle: listening, thinking, etc. -->
<p id="white-mic-caption" class="white-mic-caption" role="status">Escuchando…</p> <p id="white-mic-caption" class="white-mic-caption" role="status">Toca para empezar</p>
<p id="white-mic-subcaption" class="white-mic-subcaption" hidden></p>
<!-- Secondary stop pill below caption, with a dark stop-square icon. --> <!-- Secondary stop pill below caption, with a dark stop-square icon. -->
<button id="white-stop-btn" <button id="white-stop-btn"
class="white-stop-btn" class="white-stop-btn"
type="button" type="button"
hidden
aria-label="Detener" aria-label="Detener"
title="Detener sesión de voz"> title="Detener sesión de voz">
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><rect x="6" y="6" width="12" height="12" rx="2"/></svg> <svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><rect x="6" y="6" width="12" height="12" rx="2"/></svg>
@@ -370,16 +360,14 @@
<!-- Text chat ALWAYS VISIBLE, fixed between the stage and footer. <!-- Text chat ALWAYS VISIBLE, fixed between the stage and footer.
The panel is now a direct child of the flex-column #app, so it The panel is now a direct child of the flex-column #app, so it
no longer needs a topbar toggle for a right-side overlay. --> no longer needs a topbar toggle for a right-side overlay. -->
<div id="chat-panel" class="chat-panel chat-panel-inline" aria-label="Chat de texto"> <div id="chat-panel" class="chat-panel chat-panel-inline" role="region" aria-label="Transcripción de la conversación">
<div class="chat-panel-inner"> <div class="chat-panel-inner">
<header class="chat-panel-header"> <header class="chat-panel-header">
<h3>Conversación</h3> <h3>Conversación</h3>
<div class="chat-header-actions" aria-label="Acciones de la conversación"> <div class="chat-header-actions" role="group" aria-label="Acciones de la conversación">
<!-- Online status pill (green dot + text) — theme=white only. <span id="chat-status" class="chat-status-pill" hidden data-theme-only="white" data-status="idle" role="status" aria-live="polite">
Mirrors the screenshot chat header top-right corner. -->
<span class="chat-status-pill" hidden data-theme-only="white" role="status" aria-live="polite">
<span class="chat-status-dot" aria-hidden="true"></span> <span class="chat-status-dot" aria-hidden="true"></span>
<span>En línea</span> <span id="chat-status-label">Sin sesión</span>
</span> </span>
<button id="chat-export-btn" class="chat-export-btn icon-btn" <button id="chat-export-btn" class="chat-export-btn icon-btn"
type="button" type="button"
@@ -394,31 +382,6 @@
</div> </div>
</header> </header>
<div id="chat-history" class="chat-history" role="log" aria-live="polite" aria-atomic="false"></div> <div id="chat-history" class="chat-history" role="log" aria-live="polite" aria-atomic="false"></div>
<!-- Text input row — HIDDEN in dark theme per user request 2026-09-04.
In white theme VISIBLE with a new design (clip button + rounded input + blue circular send),
which mirrors the screenshot footer input bar.
Restore the dark-theme input by removing the `hidden` attr + style below. -->
<form id="chat-form" class="chat-form" autocomplete="off" spellcheck="false" hidden style="display:none!important">
<button id="chat-attach-btn" class="chat-attach-btn icon-btn" type="button" aria-label="Adjuntar archivo" title="Adjuntar">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/>
</svg>
</button>
<input id="chat-input"
class="chat-input"
type="text"
name="message"
placeholder="Escribe un mensaje…"
aria-label="Enviar mensaje de texto a Vocero"
maxlength="500"
/>
<button id="chat-send" type="submit" class="chat-send-btn-round" aria-label="Enviar mensaje">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="22" y1="2" x2="11" y2="13"/>
<polygon points="22 2 15 22 11 13 2 9 22 2"/>
</svg>
</button>
</form>
</div> </div>
</div> </div>
</div> </div>
+116 -213
View File
@@ -628,13 +628,13 @@ function saveTools() {
/** @type {Record<AppState, { caption: string; disabled: boolean }>} */ /** @type {Record<AppState, { caption: string; disabled: boolean }>} */
const STATE_VIEWS = { const STATE_VIEWS = {
idle: { caption: "Toca o di \"Hola Vocero\"", disabled: false }, idle: { caption: "Toca o di \"Hola Vocero\"", disabled: false },
connecting: { caption: "Conectando", disabled: true }, connecting: { caption: "Conectando…", disabled: true },
queued: { caption: "Buscando un lugar para ti…", disabled: true }, queued: { caption: "Buscando un lugar para ti…", disabled: true },
"your-turn": { caption: "¡Es tu turno! 🎉", disabled: true }, "your-turn": { caption: "¡Es tu turno! 🎉", disabled: true },
listening: { caption: "", disabled: false }, listening: { caption: "Escuchando…", disabled: true },
"user-speaking": { caption: "", disabled: false }, "user-speaking": { caption: "Te escucho…", disabled: true },
processing: { caption: "", disabled: false }, processing: { caption: "Pensando…", disabled: true },
"ai-speaking": { caption: "", disabled: false }, "ai-speaking": { caption: "Vocero está hablando…", disabled: true },
error: { caption: "Toca para reintentar", disabled: false }, error: { caption: "Toca para reintentar", disabled: false },
}; };
@@ -666,6 +666,18 @@ const orbWrap = $(".orb-wrap");
const micBtn = $("#mic-btn"); const micBtn = $("#mic-btn");
/** @type {HTMLButtonElement} */ /** @type {HTMLButtonElement} */
const stopBtn = $("#stop-btn"); const stopBtn = $("#stop-btn");
/** @type {HTMLButtonElement} */
const whiteMicBtn = $("#white-mic-circle");
/** @type {HTMLButtonElement} */
const whiteStopBtn = $("#white-stop-btn");
/** @type {HTMLParagraphElement} */
const whiteMicCaption = $("#white-mic-caption");
/** @type {HTMLParagraphElement} */
const whiteMicSubcaption = $("#white-mic-subcaption");
/** @type {HTMLElement} */
const chatStatus = $("#chat-status");
/** @type {HTMLElement} */
const chatStatusLabel = $("#chat-status-label");
/** @type {HTMLElement} */ /** @type {HTMLElement} */
const queueActions = $("#queue-actions"); const queueActions = $("#queue-actions");
/** @type {HTMLButtonElement} */ /** @type {HTMLButtonElement} */
@@ -735,7 +747,7 @@ const inputVoice = $("#voice");
const inputInstructions = $("#instructions"); const inputInstructions = $("#instructions");
/** @type {HTMLElement} */ /** @type {HTMLElement} */
const primaryPromptMeta = $("#primary-prompt-meta"); const primaryPromptMeta = $("#primary-prompt-meta");
/** @type {HTMLElement} */ /** @type {HTMLButtonElement} */
const identUserName = $("#ident-user-name"); const identUserName = $("#ident-user-name");
/** @type {HTMLElement} */ /** @type {HTMLElement} */
// NOTE: #reports-hint now lives inside #reports-sidebar (left sidebar), not // NOTE: #reports-hint now lives inside #reports-sidebar (left sidebar), not
@@ -766,14 +778,6 @@ const userProfileSubtitle = $("#user-profile-subtitle");
/** @type {HTMLElement} */ /** @type {HTMLElement} */
const userProfileMetadata = $("#user-profile-metadata"); const userProfileMetadata = $("#user-profile-metadata");
/* ─── Inline chat form (always-visible panel at the bottom of #app) ──── */
/** @type {HTMLFormElement | null} */
const chatForm = /** @type {HTMLFormElement | null} */ ($("#chat-form"));
/** @type {HTMLInputElement | null} */
const chatInput = /** @type {HTMLInputElement | null} */ ($("#chat-input"));
/** @type {HTMLButtonElement | null} */
const chatSendBtn = /** @type {HTMLButtonElement | null} */ ($("#chat-send"));
/** @type {null | { count:number, wrappedLen:number, hints:string[], entries:Array<{sourceScheduleLabel?:string, date?:any}> }} */ /** @type {null | { count:number, wrappedLen:number, hints:string[], entries:Array<{sourceScheduleLabel?:string, date?:any}> }} */
let voceroReportsList = null; let voceroReportsList = null;
let voceroReportsLoaded = false; let voceroReportsLoaded = false;
@@ -1220,7 +1224,7 @@ function populateUserProfileDialog(profile) {
* and optionally restore focus to the avatar when it closes (a11y). * and optionally restore focus to the avatar when it closes (a11y).
*/ */
function openUserProfileDialog() { function openUserProfileDialog() {
if (!userProfileDialog) return; if (!userProfileDialog || !extractVoceroUserName(voceroUserProfile)) return;
populateUserProfileDialog(/** @type {Record<string, unknown>} */ (voceroUserProfile)); populateUserProfileDialog(/** @type {Record<string, unknown>} */ (voceroUserProfile));
try { try {
userProfileDialog.showModal(); userProfileDialog.showModal();
@@ -1234,17 +1238,18 @@ function openUserProfileDialog() {
function syncVoceroHeaderUser() { function syncVoceroHeaderUser() {
const name = extractVoceroUserName(voceroUserProfile); const name = extractVoceroUserName(voceroUserProfile);
const initials = initialsForName(name); const initials = initialsForName(name);
identUserName.hidden = !name; identUserName.hidden = s2sTheme !== "white" || !name;
identUserName.disabled = !name;
if (name) { if (name) {
// Show avatar with the 2-letter initials. Full name is reachable via hover // Show avatar with the 2-letter initials. Full name is reachable via hover
// tooltip (title) and is used by screen readers via aria-label. // tooltip (title) and is used by screen readers via aria-label.
identUserName.textContent = initials; identUserName.textContent = initials;
identUserName.title = `Usuario: ${name}`; identUserName.title = `Usuario: ${name}`;
identUserName.setAttribute("aria-label", `Usuario: ${name}`); identUserName.setAttribute("aria-label", `Ver datos de ${name}`);
} else { } else {
identUserName.textContent = ""; identUserName.textContent = "";
identUserName.removeAttribute("title"); identUserName.removeAttribute("title");
identUserName.removeAttribute("aria-label"); identUserName.setAttribute("aria-label", "Ver datos del usuario");
} }
if (!reportsHint) return; if (!reportsHint) return;
@@ -1820,40 +1825,6 @@ function pushToolsToSession() {
// streaming state. The client's events are forwarded to its on* methods. // streaming state. The client's events are forwarded to its on* methods.
const chat = new ChatView(); const chat = new ChatView();
// ── Inline chat form submit (always-visible panel) ─────────────────────────
// User can send free-text messages by typing + pressing Enter (form submit) or
// tapping the gradient paper-plane send button. The text flows into the
// existing realtime client sendUserText() so it behaves exactly the same as a
// voice-transcribed phrase (onUserTranscriptFinished → server → assistant
// response). ChatView already renders bubbles/history via its on* hooks so we
// just forward the text to the socket.
if (chatForm && chatInput) {
chatForm.addEventListener("submit", (e) => {
e.preventDefault();
const raw = chatInput.value;
if (!raw) return;
const text = raw.trim();
if (!text) {
chatInput.value = "";
return;
}
// 1. Send to Vocero realtime server (identical to voice phrase submission).
try {
if (client && typeof client.sendUserText === "function") {
client.sendUserText(text);
}
} catch (err) {
// Never crash the UI if the realtime client rejected; swallow to console.
console.warn("chat-form: sendUserText threw", err);
}
// 2. Reset input. ChatView.onUserTranscriptStarted / onUserTranscriptFinished
// (already wired to client events) will render the ephemeral bubble +
// persistent history row + scroll to bottom automatically.
chatInput.value = "";
chatInput.focus();
});
}
// ── Account / limiter ───────────────────────────────────────────────────── // ── Account / limiter ─────────────────────────────────────────────────────
// Login chip + daily-limit modal (inert unless the deploy is in LB mode). The // Login chip + daily-limit modal (inert unless the deploy is in LB mode). The
// server meters conversation time; the client just heartbeats a live session // server meters conversation time; the client just heartbeats a live session
@@ -1919,7 +1890,12 @@ function _kickIdleTimer(reason = "activity") {
// user can tell what happened instead of wondering why the orb reset. // user can tell what happened instead of wondering why the orb reset.
circleSubcaption.textContent = "Sesión cerrada tras 30 segundos de silencio. Toca el círculo o repite «Hola Vocero» para empezar de nuevo."; circleSubcaption.textContent = "Sesión cerrada tras 30 segundos de silencio. Toca el círculo o repite «Hola Vocero» para empezar de nuevo.";
circleSubcaption.hidden = false; circleSubcaption.hidden = false;
window.setTimeout(() => { circleSubcaption.hidden = true; }, 8000); whiteMicSubcaption.textContent = circleSubcaption.textContent;
whiteMicSubcaption.hidden = false;
window.setTimeout(() => {
circleSubcaption.hidden = true;
whiteMicSubcaption.hidden = true;
}, 8000);
})(); })();
}, IDLE_TIMEOUT_MS); }, IDLE_TIMEOUT_MS);
if (DEBUG) { if (DEBUG) {
@@ -1938,14 +1914,41 @@ let client = null;
/** @type {MediaStream | null} */ /** @type {MediaStream | null} */
let micStream = null; let micStream = null;
let micMuted = false; let micMuted = false;
/** @type {Promise<void> | null} */
let teardownPromise = null;
function syncVoiceControls() {
const view = STATE_VIEWS[currentState];
const live = LIVE_STATES.has(currentState);
const disabled = view.disabled || !!teardownPromise;
const label = currentState === "error" ? "Reintentar conversación de voz"
: currentState === "idle" ? "Iniciar conversación de voz" : view.caption;
circleBtn.disabled = disabled;
circleBtn.setAttribute("aria-label", label);
whiteMicBtn.disabled = disabled;
whiteMicBtn.setAttribute("aria-label", label);
whiteMicBtn.className = `white-mic-circle ${STATE_CLASS[currentState]}`;
whiteStopBtn.hidden = !live || s2sTheme !== "white";
whiteStopBtn.disabled = !live || !!teardownPromise;
const waiting = currentState === "queued" || currentState === "your-turn";
chatStatus.dataset.status = live ? "online" : currentState === "error" ? "error"
: currentState === "connecting" || waiting ? "waiting" : "idle";
chatStatusLabel.textContent = live ? "En línea"
: currentState === "connecting" ? "Conectando…"
: currentState === "queued" ? "En cola"
: currentState === "your-turn" ? "Tu turno"
: currentState === "error" ? "Sin conexión" : "Sin sesión";
}
/** @param {AppState} next */ /** @param {AppState} next */
function setState(next) { function setState(next) {
const previousState = currentState;
currentState = next; currentState = next;
const view = STATE_VIEWS[next]; const view = STATE_VIEWS[next];
circleBtn.disabled = view.disabled;
circleBtn.className = `circle ${STATE_CLASS[next]}`; circleBtn.className = `circle ${STATE_CLASS[next]}`;
if (next !== "error") setCaption(view.caption); syncVoiceControls();
if (next !== "error" || previousState !== "error") setCaption(view.caption);
const live = LIVE_STATES.has(next); const live = LIVE_STATES.has(next);
orbWrap.classList.toggle("live", live); orbWrap.classList.toggle("live", live);
@@ -1973,6 +1976,8 @@ function setState(next) {
} else { } else {
circleSubcaption.hidden = true; circleSubcaption.hidden = true;
} }
whiteMicSubcaption.textContent = circleSubcaption.textContent;
whiteMicSubcaption.hidden = circleSubcaption.hidden;
// Wake word: armed only when idle/error (no live mic contention with the // Wake word: armed only when idle/error (no live mic contention with the
// realtime session), and disarmed the moment we move into any other state. // realtime session), and disarmed the moment we move into any other state.
@@ -2022,6 +2027,9 @@ function setCaption(text, kind = "") {
if (!trimmed) parts.push("empty"); if (!trimmed) parts.push("empty");
if (addIdleCta) parts.push("idle-cta"); if (addIdleCta) parts.push("idle-cta");
circleCaption.className = parts.join(" "); circleCaption.className = parts.join(" ");
whiteMicCaption.textContent = trimmed;
whiteMicCaption.classList.toggle("muted", kind === "muted");
whiteMicCaption.classList.toggle("error", kind === "error");
} }
function openSettings() { function openSettings() {
@@ -2177,22 +2185,9 @@ aboutModal.addEventListener("click", (e) => {
if (e.target === aboutModal) aboutModal.close(); if (e.target === aboutModal) aboutModal.close();
}); });
/* ─── User Profile dialog handlers (click avatar circle) ───────────── */ /* ─── User profile: native button and dialog keyboard behavior ─────── */
// The avatar circle in the brand header: when clicked, opens a native
// <dialog> with the user's profile info. Keyboard accessible: Enter / Space
// also open the dialog. a11y note: we attach role="button" + tabindex via JS
// at load time (so even if markup changes the behavior is preserved).
if (identUserName) { if (identUserName) {
identUserName.setAttribute("role", "button");
identUserName.setAttribute("tabindex", "0");
identUserName.style.cursor = "pointer";
identUserName.addEventListener("click", (e) => { e.preventDefault(); openUserProfileDialog(); }); identUserName.addEventListener("click", (e) => { e.preventDefault(); openUserProfileDialog(); });
identUserName.addEventListener("keydown", (e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
openUserProfileDialog();
}
});
} }
// Close via the explicit X button (top-right of the card). // Close via the explicit X button (top-right of the card).
if (userProfileClose) { if (userProfileClose) {
@@ -2200,6 +2195,9 @@ if (userProfileClose) {
} }
// Click in the backdrop = click whose target is the <dialog> itself. // Click in the backdrop = click whose target is the <dialog> itself.
if (userProfileDialog) { if (userProfileDialog) {
userProfileDialog.addEventListener("close", () => {
if (!identUserName.hidden) identUserName.focus();
});
userProfileDialog.addEventListener("click", (e) => { userProfileDialog.addEventListener("click", (e) => {
if (e.target === userProfileDialog) userProfileDialog.close(); if (e.target === userProfileDialog) userProfileDialog.close();
}); });
@@ -2724,48 +2722,15 @@ async function fetchConfig() {
void account.refresh(); void account.refresh();
syncToolsUi(); syncToolsUi();
syncConnectionUi(); syncConnectionUi();
// Apply theme runtime: show/hide theme-only elements, wire up white-mic // Apply the theme and refresh state-dependent controls after config resolves.
// button forwarders, unhide chat-form in white theme, etc. Must run after
// `s2sTheme` is set and the data-theme attr has been applied to html.
_applyThemeRuntime(); _applyThemeRuntime();
} }
/**
* Spanish (Castilian) label map for the "Escuchando..." status caption below
* the white-theme blue mic circle. Mirrors every client state.
*/
const WHITE_STATUS_LABELS = Object.freeze({
idle: "Toca para empezar",
connecting: "Conectando…",
queued: "En cola…",
"your-turn":"Escuchando…",
processing: "Pensando…",
"ai-speaking":"Hablando…",
error: "Algo salió mal",
stopped: "Detenido",
});
let _themeRuntimeInitialized = false; let _themeRuntimeInitialized = false;
/** /**
* Apply runtime wiring for the current theme: * Apply theme visibility, then restore the avatar and session visibility.
* 1. Flip [hidden] on every [data-theme-only="white" / "dark"] element in * Register theme-specific actions once; setState/setCaption update both views.
* the document so the correct parallel UI is shown.
* 2. Remove the hardcoded `hidden + display:none !important` on #chat-form
* in white theme (we want the text input + send button visible, just
* like the screenshot input bar).
* 3. Click forwarders: #white-mic-circle → real #main-circle,
* #white-stop-btn → real #stop-btn.
* 4. MutationObserver on #main-circle `.className` to mirror every
* `state-*` class live to #white-mic-circle so it swaps its visuals
* (mic icon / stop icon / spinner) exactly in sync with the orb.
* 5. Wrap `setCaption()` so the Spanish label `#white-mic-caption` gets
* updated at the same time as the default orb caption.
* 6. Quick action button listeners: populate chat-input / or send via
* client.sendUserText() when a session is live.
* Called by fetchConfig() AFTER s2sTheme and <html data-theme> are set.
* Re-runs safely on every config fetch (guard `_themeRuntimeInitialized` for
* the one-shot observer / listeners).
*/ */
function _applyThemeRuntime() { function _applyThemeRuntime() {
const isWhite = s2sTheme === "white"; const isWhite = s2sTheme === "white";
@@ -2793,109 +2758,29 @@ function _applyThemeRuntime() {
} }
}); });
// 2. Chat form input: in WHITE theme, we need the text input visible to syncVoceroHeaderUser();
// match the screenshot. The HTML ships with `hidden` + inline syncVoiceControls();
// `display:none !important` because the original dark theme hid it
// (per user request 2026-09-04). Clear both here, restore when leaving.
const chatForm = document.getElementById("chat-form");
if (chatForm) {
if (isWhite) {
chatForm.removeAttribute("hidden");
chatForm.style.removeProperty("display");
} else if (!chatForm.hasAttribute("hidden")) {
// Reapply original dark-theme defaults (preserve existing user edit).
chatForm.setAttribute("hidden", "");
chatForm.style.setProperty("display", "none", "important");
}
}
// One-shot wiring (never re-add listeners/observer on subsequent calls). // One-shot wiring (never re-add listeners on subsequent calls).
if (_themeRuntimeInitialized) return; if (_themeRuntimeInitialized) return;
_themeRuntimeInitialized = true; _themeRuntimeInitialized = true;
// 3a. Click forwarder: white mic circle → the real start/stop orb handler. // Start through the same handler as the dark-theme orb.
const whiteMic = document.getElementById("white-mic-circle"); const whiteMic = document.getElementById("white-mic-circle");
const realCircle = document.getElementById("main-circle"); const realCircle = document.getElementById("main-circle");
if (whiteMic && realCircle) { if (whiteMic && realCircle) {
whiteMic.addEventListener("click", (ev) => { whiteMic.addEventListener("click", (ev) => {
ev.preventDefault(); ev.preventDefault();
ev.stopPropagation(); ev.stopPropagation();
realCircle.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true })); if (!whiteMic.disabled) realCircle.click();
}); });
} }
// 3b. Click forwarder: white "Detener" pill → hidden real stop side-btn. whiteStopBtn.addEventListener("click", async () => {
const whiteStop = document.getElementById("white-stop-btn"); if (LIVE_STATES.has(currentState)) await teardown();
const realStop = document.getElementById("stop-btn");
if (whiteStop && realStop) {
whiteStop.addEventListener("click", (ev) => {
ev.preventDefault();
ev.stopPropagation();
realStop.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
}); });
}
// 4. Live sync of #main-circle className → #white-mic-circle className // Retained quick actions are only available to an active realtime session.
// (states: .state-idle, .state-connecting, .state-your-turn, .state-processing,
// .state-ai-speaking, .state-error, .state-queued). White-mic styles
// use these same state-* classes (CSS prefixes with .white-mic-circle.state-*)
// so we simply shadow every class on the orb button to the new button.
if (whiteMic && realCircle && typeof MutationObserver !== "undefined") {
const syncStateClasses = () => {
// Keep the base class + data-theme-only marker but clear/re-add the rest.
const tokens = Array.from(realCircle.classList);
for (const cl of Array.from(whiteMic.classList)) {
if (cl.startsWith("state-")) whiteMic.classList.remove(cl);
}
for (const cl of tokens) {
if (cl.startsWith("state-")) whiteMic.classList.add(cl);
}
// Also show/hide the white stop pill in sync with the real stop-btn
// (which is shown when connected).
if (whiteStop) {
const visible = !realStop.hasAttribute("aria-hidden") || realStop.getAttribute("aria-hidden") === "false";
if (visible) {
whiteStop.style.removeProperty("display");
whiteStop.removeAttribute("hidden");
} else {
whiteStop.setAttribute("hidden", "");
}
}
};
syncStateClasses();
const obs = new MutationObserver(() => syncStateClasses());
obs.observe(realCircle, { attributes: true, attributeFilter: ["class", "aria-hidden"] });
if (realStop) obs.observe(realStop, { attributes: true, attributeFilter: ["aria-hidden", "class"] });
// 5. Set the initial Spanish caption for theme=white + wrap setCaption()
// so any future setCaption() call also updates the Spanish label.
const capWhite = document.getElementById("white-mic-caption");
if (capWhite) {
capWhite.textContent = WHITE_STATUS_LABELS[currentState] || WHITE_STATUS_LABELS.idle;
const origSetCaption = setCaption;
// Re-assign the global (window / module scope var holds function ref) so
// each caller of setCaption(text, kind) now also drives the Spanish label.
// We intentionally DON'T replace the signature — old code keeps working.
(function (prev) {
window.setCaption = function (text, kind) {
prev(text, kind);
// When in white theme we prefer the Spanish state-based label from
// WHITE_STATUS_LABELS whenever possible, falling back to raw text.
if (s2sTheme === "white" && capWhite) {
const fromState = WHITE_STATUS_LABELS[currentState];
capWhite.textContent = fromState || text || WHITE_STATUS_LABELS.idle;
const muted = kind === "muted";
capWhite.classList.toggle("muted", !!muted);
capWhite.classList.toggle("error", kind === "error");
}
};
})(origSetCaption);
}
}
// 6. Quick action buttons for summaries, data lookup and response drafts
// populate the text input or — if a realtime
// session is live — sends it as user text straight away.
document.querySelectorAll(".quick-action-btn").forEach(btn => { document.querySelectorAll(".quick-action-btn").forEach(btn => {
btn.addEventListener("click", () => { btn.addEventListener("click", () => {
const action = btn.getAttribute("data-action") || ""; const action = btn.getAttribute("data-action") || "";
@@ -2907,16 +2792,7 @@ function _applyThemeRuntime() {
const text = prompts[action] || ""; const text = prompts[action] || "";
if (!text) return; if (!text) return;
if (client && typeof client.sendUserText === "function") { if (client && typeof client.sendUserText === "function") {
// Live session: send directly as if the user typed + pressed enter.
client.sendUserText(text); client.sendUserText(text);
} else {
// No session: prefill the text input so they can send manually later.
const inp = document.getElementById("chat-input");
if (inp) {
inp.value = text;
inp.dispatchEvent(new Event("input", { bubbles: true }));
inp.focus({ preventScroll: false });
}
} }
}); });
}); });
@@ -3301,6 +3177,7 @@ async function doStart(audioContext = null) {
}); });
c.addEventListener("status", (e) => { c.addEventListener("status", (e) => {
if (client !== c) return;
const detail = /** @type {CustomEvent<{ status: string }>} */ (e).detail; const detail = /** @type {CustomEvent<{ status: string }>} */ (e).detail;
_kickIdleTimer(`client-status:${detail.status}`); _kickIdleTimer(`client-status:${detail.status}`);
onClientStatus(detail.status); onClientStatus(detail.status);
@@ -3340,15 +3217,19 @@ async function doStart(audioContext = null) {
}); });
}); });
c.addEventListener("error", (e) => { c.addEventListener("error", (e) => {
if (client !== c) return;
const detail = /** @type {CustomEvent<{ error: unknown }>} */ (e).detail; const detail = /** @type {CustomEvent<{ error: unknown }>} */ (e).detail;
onFatalError(detail.error); onFatalError(detail.error);
}); });
c.addEventListener("server-error", (e) => { c.addEventListener("server-error", (e) => {
// Non-fatal: the backend reported an error mid-session. Log it, keep the // Non-fatal: the backend reported an error mid-session. Log it, keep the
// socket and the conversation alive (the model can recover on its own). // socket and the conversation alive while showing an actionable message.
const detail = /** @type {CustomEvent<{ error: unknown }>} */ (e).detail; const detail = /** @type {CustomEvent<{ error: unknown }>} */ (e).detail;
const msg = detail.error instanceof Error ? detail.error.message : String(detail.error); const msg = detail.error instanceof Error ? detail.error.message : String(detail.error);
console.warn("[main] server error (non-fatal):", msg); console.warn("[main] server error (non-fatal):", msg);
if (client === c) {
setCaption("Vocero no pudo completar la respuesta. Intenta hablar de nuevo.", "error");
}
}); });
c.addEventListener("session", (e) => { c.addEventListener("session", (e) => {
const info = /** @type {CustomEvent<{ info: import("./ws/s2s-ws-client.js").WsSessionInfo }>} */ (e).detail.info; const info = /** @type {CustomEvent<{ info: import("./ws/s2s-ws-client.js").WsSessionInfo }>} */ (e).detail.info;
@@ -3512,7 +3393,9 @@ function onClientStatus(status) {
setState("ai-speaking"); setState("ai-speaking");
break; break;
case "closed": case "closed":
// teardown() will move us to idle // An intentional close clears client before closing the socket. A remote
// close still needs to release the mic and clear the online UI.
if (client) void teardown();
break; break;
case "error": case "error":
setState("error"); setState("error");
@@ -3520,7 +3403,21 @@ function onClientStatus(status) {
} }
} }
async function teardown() { /** @param {AppState} [finalState] @returns {Promise<void>} */
function teardown(finalState = "idle") {
if (teardownPromise) return teardownPromise;
const closingClient = client;
client = null;
teardownPromise = finishTeardown(closingClient, finalState).finally(() => {
teardownPromise = null;
syncVoiceControls();
});
syncVoiceControls();
return teardownPromise;
}
/** @param {S2sWsRealtimeClient | null} closingClient @param {AppState} finalState */
async function finishTeardown(closingClient, finalState) {
stopHeartbeat(); stopHeartbeat();
stopJoinCountdown(); stopJoinCountdown();
endTrackedSession(); endTrackedSession();
@@ -3531,13 +3428,12 @@ async function teardown() {
// user left queue, limit exceeded) the pending wake-word confirmation would // user left queue, limit exceeded) the pending wake-word confirmation would
// be re-injected on the next session. Clear it so we never double-fire. // be re-injected on the next session. Clear it so we never double-fire.
pendingWakeWordConfirmationText = ""; pendingWakeWordConfirmationText = "";
if (client) { if (closingClient) {
try { try {
await client.close(); await closingClient.close();
} catch (err) { } catch (err) {
console.warn("[main] error closing client:", err); console.warn("[main] error closing client:", err);
} }
client = null;
} }
if (micStream) { if (micStream) {
for (const track of micStream.getTracks()) track.stop(); for (const track of micStream.getTracks()) track.stop();
@@ -3545,7 +3441,7 @@ async function teardown() {
} }
micMuted = false; micMuted = false;
micBtn.classList.remove("muted"); micBtn.classList.remove("muted");
setState("idle"); setState(finalState);
// Refresh the chip's remaining-today after the budget moved. // Refresh the chip's remaining-today after the budget moved.
if (limiterOn) void account.refresh(); if (limiterOn) void account.refresh();
} }
@@ -3553,13 +3449,20 @@ async function teardown() {
/** @param {unknown} err */ /** @param {unknown} err */
function onFatalError(err) { function onFatalError(err) {
console.error("[main] fatal:", err); console.error("[main] fatal:", err);
const wasLive = LIVE_STATES.has(currentState);
setState("error"); setState("error");
const message = err instanceof Error ? err.message : String(err); const message = err instanceof Error ? err.message : String(err);
setCaption(truncateError(message), "error"); const caption = message.includes("micrófono")
void teardown().catch(() => { ? `${truncateError(message)} Toca para reintentar.`
: wasLive
? "Se interrumpió la conexión con Vocero. Revisa tu conexión y toca para reintentar."
: "No se pudo conectar con Vocero. Revisa tu conexión y toca para reintentar.";
setCaption(caption, "error");
const showError = () => {
setState("error"); setState("error");
setCaption(truncateError(message), "error"); setCaption(caption, "error");
}); };
void teardown("error").then(showError, showError);
} }
setState("idle"); setState("idle");
+48 -216
View File
@@ -505,7 +505,7 @@ a:hover {
text-transform: uppercase; text-transform: uppercase;
text-shadow: 0 1px 2px rgba(0,0,0,0.35); text-shadow: 0 1px 2px rgba(0,0,0,0.35);
user-select: none; user-select: none;
cursor: default; cursor: pointer;
transition: transition:
transform .22s cubic-bezier(.2,.7,.2,1), transform .22s cubic-bezier(.2,.7,.2,1),
box-shadow .3s ease, box-shadow .3s ease,
@@ -541,6 +541,11 @@ a:hover {
0 6px 22px rgba(0,0,0,0.28), 0 6px 22px rgba(0,0,0,0.28),
0 0 34px -6px color-mix(in srgb, var(--brand) 60%, transparent); 0 0 34px -6px color-mix(in srgb, var(--brand) 60%, transparent);
} }
#ident-user-name[hidden],
#white-stop-btn[hidden],
#white-mic-subcaption[hidden] {
display: none !important;
}
@keyframes pulseDot { @keyframes pulseDot {
0%, 100% { opacity: 1; transform: scale(1); } 0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: .78; transform: scale(.9); } 50% { opacity: .78; transform: scale(.9); }
@@ -2459,88 +2464,6 @@ body.cam-on .footer {
background-clip: content-box; background-clip: content-box;
} }
/* ─── Chat input row (bottom of chat panel, above the button) ──────── */
/* NOTE: currently HIDDEN in index.html. Kept seamless here so if/when we
re-enable it the form melts visually into the page bg, no separator frame. */
.chat-form {
display: flex;
align-items: stretch;
gap: 8px;
padding: 8px 2px 0;
border-top: 0 solid transparent;
background: transparent;
flex: none;
}
.chat-input {
flex: 1 1 auto;
min-width: 0;
height: 40px;
padding: 0 14px;
border-radius: 12px;
background: color-mix(in srgb, var(--bg) 70%, rgba(255,255,255,0.02));
border: 1px solid color-mix(in srgb, var(--brand) 18%, var(--border));
color: var(--text);
font-size: 14px;
font-family: var(--font-sans);
line-height: 1;
transition: border-color .2s ease, box-shadow .2s ease, background .2s ease;
outline: none;
box-shadow: 0 0 0 0 transparent;
}
.chat-input::placeholder {
color: color-mix(in srgb, var(--text-dim) 72%, transparent);
letter-spacing: 0.01em;
}
.chat-input:hover {
border-color: color-mix(in srgb, var(--brand-2) 44%, var(--border));
}
.chat-input:focus {
border-color: color-mix(in srgb, var(--brand-3) 60%, transparent);
background: color-mix(in srgb, var(--bg) 82%, rgba(255,255,255,0.04));
box-shadow:
0 0 0 3px color-mix(in srgb, var(--brand-3) 22%, transparent),
0 0 26px -8px color-mix(in srgb, var(--brand-3) 70%, transparent);
}
.chat-send-btn {
flex: none;
width: 40px;
height: 40px;
border-radius: 12px;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
color: #fff;
background:
radial-gradient(120% 120% at 30% 18%, rgba(255,255,255,0.18), transparent 58%),
linear-gradient(135deg,
color-mix(in srgb, var(--brand) 82%, transparent),
color-mix(in srgb, var(--brand-2) 70%, transparent) 55%,
color-mix(in srgb, var(--brand-3) 70%, transparent));
border: 1px solid color-mix(in srgb, var(--brand-2) 50%, var(--border));
box-shadow:
0 1px 0 rgba(255,255,255,0.1) inset,
0 6px 18px rgba(0,0,0,0.30),
0 0 24px -8px color-mix(in srgb, var(--brand-2) 70%, transparent);
cursor: pointer;
transition: transform .22s ease, box-shadow .3s ease, filter .3s ease;
}
.chat-send-btn:hover {
transform: translateY(-1px);
filter: brightness(1.05);
box-shadow:
0 1px 0 rgba(255,255,255,0.12) inset,
0 10px 22px rgba(0,0,0,0.34),
0 0 34px -6px color-mix(in srgb, var(--brand-2) 80%, transparent);
}
.chat-send-btn:active { transform: translateY(0) scale(0.98); }
.chat-send-btn:focus-visible {
outline: none;
box-shadow:
0 0 0 3px color-mix(in srgb, var(--brand-3) 40%, transparent),
0 6px 18px rgba(0,0,0,0.30);
}
/* ─── Reports sidebar (LEFT) ───────────────────────────────────────── */ /* ─── Reports sidebar (LEFT) ───────────────────────────────────────── */
/* Behaviour: /* Behaviour:
- desktop (>= 1024px): FIXED to the left edge, 320px, always visible. - desktop (>= 1024px): FIXED to the left edge, 320px, always visible.
@@ -4396,29 +4319,14 @@ html[data-theme="white"] .talk-unlocker-spinner {
html[data-theme="white"] .talk-unlocker-title { color: #0f172a; } html[data-theme="white"] .talk-unlocker-title { color: #0f172a; }
html[data-theme="white"] .talk-unlocker-sub { color: rgba(15,23,42,0.65); } html[data-theme="white"] .talk-unlocker-sub { color: rgba(15,23,42,0.65); }
/* ── Topbar — flat, no card, center brand, hamburger left, avatar right ── */ /* ── Topbar — flat, no card, centered brand and avatar right ── */
html[data-theme="white"] .topbar { html[data-theme="white"] .topbar {
padding: 18px 18px 6px 18px; padding: 18px 18px 6px 18px;
align-items: center; align-items: center;
gap: 10px; gap: 10px;
} }
html[data-theme="white"] .menu-btn {
color: #0f172a;
width: 44px;
height: 44px;
padding: 0;
border-radius: 12px;
display: inline-flex !important;
align-items: center;
justify-content: center;
opacity: 0.9;
}
html[data-theme="white"] .menu-btn:hover {
background: rgba(15, 23, 42, 0.05);
opacity: 1;
}
/* ── Topbar layout (theme=white): 3-column grid ────────────────────────── /* ── Topbar layout (theme=white): 3-column grid ──────────────────────────
Col1 = hamburger 52 px · Col2 = brand centered · Col3 = user avatar 52 px Balanced side gutters keep the brand centered with an optional avatar.
Using a real grid eliminates absolute-position overlap bugs between the Using a real grid eliminates absolute-position overlap bugs between the
avatar circle and the 2-line brand title/subtitle that plagued the previous avatar circle and the 2-line brand title/subtitle that plagued the previous
implementation. Every item lives inside its own grid cell → no collision. */ implementation. Every item lives inside its own grid cell → no collision. */
@@ -4437,13 +4345,6 @@ html[data-theme="white"] .topbar {
background: transparent; background: transparent;
border: none; border: none;
} }
html[data-theme="white"] .menu-btn {
grid-column: 1 / 2;
grid-row: 1 / 2;
justify-self: start;
align-self: center;
margin: 0 0 0 2px;
}
/* Brand card → strip glass, becomes a transparent centered title block */ /* Brand card → strip glass, becomes a transparent centered title block */
html[data-theme="white"] .brand { html[data-theme="white"] .brand {
grid-column: 2 / 3; grid-column: 2 / 3;
@@ -4502,7 +4403,7 @@ html[data-theme="white"] .ident-user-name {
width: 42px; width: 42px;
height: 42px; height: 42px;
border-radius: 999px; border-radius: 999px;
background: linear-gradient(135deg, #3b82f6 0%, #60a5fa 100%); background: linear-gradient(135deg, #1d4ed8 0%, #2563eb 100%);
color: #ffffff; color: #ffffff;
font-size: 13px; font-size: 13px;
font-weight: 700; font-weight: 700;
@@ -4511,6 +4412,10 @@ html[data-theme="white"] .ident-user-name {
justify-content: center; justify-content: center;
border: 2px solid rgba(59, 130, 246, 0.25); border: 2px solid rgba(59, 130, 246, 0.25);
box-shadow: 0 3px 10px rgba(37, 99, 235, 0.22); box-shadow: 0 3px 10px rgba(37, 99, 235, 0.22);
text-shadow: none;
}
html[data-theme="white"] .ident-user-name::before {
content: none;
} }
html[data-theme="white"] .ident-title { html[data-theme="white"] .ident-title {
font-size: clamp(24px, 6vw, 32px); font-size: clamp(24px, 6vw, 32px);
@@ -4682,7 +4587,10 @@ html[data-theme="white"] .white-mic-circle .wmc-icon {
rules below are written with high specificity and !important on the rules below are written with high specificity and !important on the
display property so they cannot be overridden by accidental resets. */ display property so they cannot be overridden by accidental resets. */
html[data-theme="white"] .white-mic-circle.state-idle > svg.wmc-icon.wmc-mic, html[data-theme="white"] .white-mic-circle.state-idle > svg.wmc-icon.wmc-mic,
html[data-theme="white"] .white-mic-circle.state-idle .wmc-icon.wmc-mic { html[data-theme="white"] .white-mic-circle.state-idle .wmc-icon.wmc-mic,
html[data-theme="white"] .white-mic-circle.state-listening .wmc-icon.wmc-mic,
html[data-theme="white"] .white-mic-circle.state-user-speaking .wmc-icon.wmc-mic,
html[data-theme="white"] .white-mic-circle.state-ai-speaking .wmc-icon.wmc-mic {
display: inline-flex !important; display: inline-flex !important;
} }
html[data-theme="white"] .white-mic-circle.state-your-turn > svg.wmc-icon.wmc-mic, html[data-theme="white"] .white-mic-circle.state-your-turn > svg.wmc-icon.wmc-mic,
@@ -4711,13 +4619,13 @@ html[data-theme="white"] .white-mic-circle.state-queued .wmc-icon.wmc-spinner {
background: transparent !important; background: transparent !important;
box-sizing: border-box; box-sizing: border-box;
} }
html[data-theme="white"] .white-mic-circle.state-ai-speaking > svg.wmc-icon.wmc-stop, html[data-theme="white"] .white-mic-circle:disabled {
html[data-theme="white"] .white-mic-circle.state-ai-speaking .wmc-icon.wmc-stop { cursor: default;
display: inline-flex !important; }
width: 34px !important; html[data-theme="white"] .white-mic-circle:focus-visible,
height: 34px !important; html[data-theme="white"] .white-stop-btn:focus-visible {
margin: 7px !important; outline: 3px solid #1d4ed8;
border-radius: 4px !important; outline-offset: 4px;
} }
/* Subtle ring pulse while listening — uses box-shadow because the ring /* Subtle ring pulse while listening — uses box-shadow because the ring
itself is a hard 4-px painted border and transform:scale() would also itself is a hard 4-px painted border and transform:scale() would also
@@ -4789,6 +4697,14 @@ html[data-theme="white"] .white-mic-caption {
} }
html[data-theme="white"] .white-mic-caption.muted { color: #64748b; } html[data-theme="white"] .white-mic-caption.muted { color: #64748b; }
html[data-theme="white"] .white-mic-caption.error { color: #dc2626; } html[data-theme="white"] .white-mic-caption.error { color: #dc2626; }
html[data-theme="white"] .white-mic-subcaption {
margin: 0;
max-width: 440px;
color: #475569;
font-size: 13px;
line-height: 1.5;
text-align: center;
}
/* ── "Detener" pill ─────────────────────────────────────────────────── */ /* ── "Detener" pill ─────────────────────────────────────────────────── */
html[data-theme="white"] .white-stop-btn { html[data-theme="white"] .white-stop-btn {
@@ -4911,32 +4827,38 @@ html[data-theme="white"] .chat-panel-header {
border-bottom-width: 1px !important; border-bottom-width: 1px !important;
text-decoration: none !important; text-decoration: none !important;
} }
/* Online status pill — green dot + text */ /* Session status uses connection events rather than a permanent online badge. */
html[data-theme="white"] .chat-status-pill { html[data-theme="white"] .chat-status-pill {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 7px; gap: 7px;
padding: 5px 11px 5px 8px; padding: 5px 11px 5px 8px;
background: rgba(22, 163, 74, 0.08); background: #f1f5f9;
border-radius: 999px; border-radius: 999px;
color: #16a34a; color: #475569;
font-size: 12.5px; font-size: 12.5px;
font-weight: 600; font-weight: 600;
flex: none; flex: none;
} }
html[data-theme="white"] .chat-status-pill[data-status="online"] {
background: #ecfdf5;
color: #15803d;
}
html[data-theme="white"] .chat-status-pill[data-status="waiting"] {
background: #fffbeb;
color: #92400e;
}
html[data-theme="white"] .chat-status-pill[data-status="error"] {
background: #fef2f2;
color: #b91c1c;
}
html[data-theme="white"] .chat-status-dot { html[data-theme="white"] .chat-status-dot {
width: 9px; width: 9px;
height: 9px; height: 9px;
border-radius: 999px; border-radius: 999px;
background: #16a34a; background: currentColor;
box-shadow: 0 0 0 3px rgba(22, 163, 74, 0.14);
display: block; display: block;
flex: none; flex: none;
animation: chatStatusDotPulse 2s ease-in-out infinite;
}
@keyframes chatStatusDotPulse {
0%, 100% { box-shadow: 0 0 0 3px rgba(22, 163, 74, 0.14); }
50% { box-shadow: 0 0 0 5px rgba(22, 163, 74, 0.08); }
} }
/* ── Chat header actions row: online status pill + conversation export button. /* ── Chat header actions row: online status pill + conversation export button.
@@ -5297,95 +5219,6 @@ html[data-theme="white"] .chat-history .hist-msg.tool .hist-body::before {
display: none !important; display: none !important;
} }
/* ── Chat form (input footer bar): clip · rounded input · blue send ────── */
html[data-theme="white"] .chat-form {
display: flex !important;
align-items: center;
gap: 10px;
padding: 6px 6px 6px 6px;
background: #ffffff;
border: 1px solid rgba(15, 23, 42, 0.08);
border-radius: 999px;
margin: 4px 0 0;
box-shadow: var(--shadow-soft);
/* Keep the input row pinned at the bottom of the chat card header; never
let the history (flex:1) squeeze the form out of the panel. */
flex: none !important;
}
html[data-theme="white"] .chat-form[hidden],
html[data-theme="white"] .chat-form[style*="display:none"] {
display: flex !important;
}
html[data-theme="white"] .chat-attach-btn {
flex: none;
width: 44px;
height: 44px;
padding: 0;
border-radius: 999px;
color: #64748b;
display: inline-flex;
align-items: center;
justify-content: center;
}
html[data-theme="white"] .chat-attach-btn:hover {
color: #2563eb;
background: rgba(37, 99, 235, 0.08);
}
html[data-theme="white"] .chat-input {
flex: 1 1 auto;
min-width: 0;
height: 44px;
padding: 0 10px 0 6px;
margin: 0;
background: transparent !important;
border: none !important;
color: #0f172a;
font-size: clamp(14px, 3.2vw, 16px);
border-radius: 0;
box-shadow: none !important;
outline: none !important;
}
html[data-theme="white"] .chat-input::placeholder {
color: rgba(15, 23, 42, 0.48);
}
html[data-theme="white"] .chat-input:focus {
border-color: transparent;
box-shadow: none;
outline: none;
}
/* Hide original square send button in white theme */
html[data-theme="white"] .chat-form .chat-send-btn {
display: none !important;
}
/* New big round blue send button with paper airplane */
html[data-theme="white"] .chat-send-btn-round {
flex: none;
width: 48px;
height: 48px;
padding: 0;
border: none;
border-radius: 999px;
background: linear-gradient(135deg, #2563eb, #3b82f6);
color: #ffffff;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
box-shadow: 0 4px 12px rgba(37, 99, 235, 0.28), inset 0 1px 0 rgba(255, 255, 255, 0.3);
transition: transform 140ms ease, filter 140ms ease;
}
html[data-theme="white"] .chat-send-btn-round:hover {
transform: translateY(-1px);
filter: brightness(1.05);
}
html[data-theme="white"] .chat-send-btn-round svg {
width: 22px;
height: 22px;
stroke-width: 2.2;
fill: #ffffff;
stroke: #ffffff;
}
/* ── App footer: thin white bar, just a subtle separator + brand line ─── */ /* ── App footer: thin white bar, just a subtle separator + brand line ─── */
html[data-theme="white"] .app-footer { html[data-theme="white"] .app-footer {
background: #ffffff; background: #ffffff;
@@ -5512,7 +5345,6 @@ html[data-theme="white"] .switch input:checked + .switch-track {
html[data-theme="white"] .white-mic-circle.state-processing .wmc-spinner, html[data-theme="white"] .white-mic-circle.state-processing .wmc-spinner,
html[data-theme="white"] .white-mic-circle.state-connecting .wmc-spinner, html[data-theme="white"] .white-mic-circle.state-connecting .wmc-spinner,
html[data-theme="white"] .white-mic-circle.state-queued .wmc-spinner { width: 34px; height: 34px; } html[data-theme="white"] .white-mic-circle.state-queued .wmc-spinner { width: 34px; height: 34px; }
html[data-theme="white"] .white-mic-circle.state-ai-speaking .wmc-stop { width: 30px; height: 30px; }
html[data-theme="white"] .white-waveform .w-bar { height: calc(var(--bar-h, 0.5) * 56px); } html[data-theme="white"] .white-waveform .w-bar { height: calc(var(--bar-h, 0.5) * 56px); }
html[data-theme="white"] .white-waveform { height: 56px; } html[data-theme="white"] .white-waveform { height: 56px; }
html[data-theme="white"] .white-waveform-left { left: calc(50% - 49px - 76px); top: calc(14px + 49px); } html[data-theme="white"] .white-waveform-left { left: calc(50% - 49px - 76px); top: calc(14px + 49px); }