From a149f4ef8bcf8e7e781fdb37e43a9b493004d74d Mon Sep 17 00:00:00 2001 From: Alvaro Parker Date: Mon, 5 Oct 2026 11:30:47 -0300 Subject: [PATCH] fix(ui): simplify Vocero controls and correct session feedback --- s2s-client/index.html | 55 ++----- s2s-client/main.js | 331 +++++++++++++++--------------------------- s2s-client/style.css | 264 ++++++--------------------------- 3 files changed, 174 insertions(+), 476 deletions(-) diff --git a/s2s-client/index.html b/s2s-client/index.html index ffeada8..86ed6f7 100644 --- a/s2s-client/index.html +++ b/s2s-client/index.html @@ -126,15 +126,6 @@
- -
@@ -147,7 +138,7 @@ - + + @@ -311,12 +299,14 @@
-

Escuchando…

+

Toca para empezar

+
- -
diff --git a/s2s-client/main.js b/s2s-client/main.js index 7c956c0..0d8f61b 100644 --- a/s2s-client/main.js +++ b/s2s-client/main.js @@ -628,13 +628,13 @@ function saveTools() { /** @type {Record} */ const STATE_VIEWS = { 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 }, "your-turn": { caption: "¡Es tu turno! 🎉", disabled: true }, - listening: { caption: "", disabled: false }, - "user-speaking": { caption: "", disabled: false }, - processing: { caption: "", disabled: false }, - "ai-speaking": { caption: "", disabled: false }, + listening: { caption: "Escuchando…", disabled: true }, + "user-speaking": { caption: "Te escucho…", disabled: true }, + processing: { caption: "Pensando…", disabled: true }, + "ai-speaking": { caption: "Vocero está hablando…", disabled: true }, error: { caption: "Toca para reintentar", disabled: false }, }; @@ -666,6 +666,18 @@ const orbWrap = $(".orb-wrap"); const micBtn = $("#mic-btn"); /** @type {HTMLButtonElement} */ 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} */ const queueActions = $("#queue-actions"); /** @type {HTMLButtonElement} */ @@ -735,7 +747,7 @@ const inputVoice = $("#voice"); const inputInstructions = $("#instructions"); /** @type {HTMLElement} */ const primaryPromptMeta = $("#primary-prompt-meta"); -/** @type {HTMLElement} */ +/** @type {HTMLButtonElement} */ const identUserName = $("#ident-user-name"); /** @type {HTMLElement} */ // NOTE: #reports-hint now lives inside #reports-sidebar (left sidebar), not @@ -766,14 +778,6 @@ const userProfileSubtitle = $("#user-profile-subtitle"); /** @type {HTMLElement} */ 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}> }} */ let voceroReportsList = null; let voceroReportsLoaded = false; @@ -1220,7 +1224,7 @@ function populateUserProfileDialog(profile) { * and optionally restore focus to the avatar when it closes (a11y). */ function openUserProfileDialog() { - if (!userProfileDialog) return; + if (!userProfileDialog || !extractVoceroUserName(voceroUserProfile)) return; populateUserProfileDialog(/** @type {Record} */ (voceroUserProfile)); try { userProfileDialog.showModal(); @@ -1234,17 +1238,18 @@ function openUserProfileDialog() { function syncVoceroHeaderUser() { const name = extractVoceroUserName(voceroUserProfile); const initials = initialsForName(name); - identUserName.hidden = !name; + identUserName.hidden = s2sTheme !== "white" || !name; + identUserName.disabled = !name; if (name) { // Show avatar with the 2-letter initials. Full name is reachable via hover // tooltip (title) and is used by screen readers via aria-label. identUserName.textContent = initials; identUserName.title = `Usuario: ${name}`; - identUserName.setAttribute("aria-label", `Usuario: ${name}`); + identUserName.setAttribute("aria-label", `Ver datos de ${name}`); } else { identUserName.textContent = ""; identUserName.removeAttribute("title"); - identUserName.removeAttribute("aria-label"); + identUserName.setAttribute("aria-label", "Ver datos del usuario"); } if (!reportsHint) return; @@ -1820,40 +1825,6 @@ function pushToolsToSession() { // streaming state. The client's events are forwarded to its on* methods. 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 ───────────────────────────────────────────────────── // 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 @@ -1919,7 +1890,12 @@ function _kickIdleTimer(reason = "activity") { // 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.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); if (DEBUG) { @@ -1938,14 +1914,41 @@ let client = null; /** @type {MediaStream | null} */ let micStream = null; let micMuted = false; +/** @type {Promise | 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 */ function setState(next) { + const previousState = currentState; currentState = next; const view = STATE_VIEWS[next]; - circleBtn.disabled = view.disabled; 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); orbWrap.classList.toggle("live", live); @@ -1973,6 +1976,8 @@ function setState(next) { } else { circleSubcaption.hidden = true; } + whiteMicSubcaption.textContent = circleSubcaption.textContent; + whiteMicSubcaption.hidden = circleSubcaption.hidden; // 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. @@ -2022,6 +2027,9 @@ function setCaption(text, kind = "") { if (!trimmed) parts.push("empty"); if (addIdleCta) parts.push("idle-cta"); circleCaption.className = parts.join(" "); + whiteMicCaption.textContent = trimmed; + whiteMicCaption.classList.toggle("muted", kind === "muted"); + whiteMicCaption.classList.toggle("error", kind === "error"); } function openSettings() { @@ -2177,22 +2185,9 @@ aboutModal.addEventListener("click", (e) => { if (e.target === aboutModal) aboutModal.close(); }); -/* ─── User Profile dialog handlers (click avatar circle) ───────────── */ -// The avatar circle in the brand header: when clicked, opens a native -// 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). +/* ─── User profile: native button and dialog keyboard behavior ─────── */ if (identUserName) { - identUserName.setAttribute("role", "button"); - identUserName.setAttribute("tabindex", "0"); - identUserName.style.cursor = "pointer"; 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). if (userProfileClose) { @@ -2200,6 +2195,9 @@ if (userProfileClose) { } // Click in the backdrop = click whose target is the itself. if (userProfileDialog) { + userProfileDialog.addEventListener("close", () => { + if (!identUserName.hidden) identUserName.focus(); + }); userProfileDialog.addEventListener("click", (e) => { if (e.target === userProfileDialog) userProfileDialog.close(); }); @@ -2724,48 +2722,15 @@ async function fetchConfig() { void account.refresh(); syncToolsUi(); syncConnectionUi(); - // Apply theme runtime: show/hide theme-only elements, wire up white-mic - // 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. + // Apply the theme and refresh state-dependent controls after config resolves. _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; /** - * Apply runtime wiring for the current theme: - * 1. Flip [hidden] on every [data-theme-only="white" / "dark"] element in - * 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 are set. - * Re-runs safely on every config fetch (guard `_themeRuntimeInitialized` for - * the one-shot observer / listeners). + * Apply theme visibility, then restore the avatar and session visibility. + * Register theme-specific actions once; setState/setCaption update both views. */ function _applyThemeRuntime() { const isWhite = s2sTheme === "white"; @@ -2793,109 +2758,29 @@ function _applyThemeRuntime() { } }); - // 2. Chat form input: in WHITE theme, we need the text input visible to - // match the screenshot. The HTML ships with `hidden` + inline - // `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"); - } - } + syncVoceroHeaderUser(); + syncVoiceControls(); - // One-shot wiring (never re-add listeners/observer on subsequent calls). + // One-shot wiring (never re-add listeners on subsequent calls). if (_themeRuntimeInitialized) return; _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 realCircle = document.getElementById("main-circle"); if (whiteMic && realCircle) { whiteMic.addEventListener("click", (ev) => { ev.preventDefault(); 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. - const whiteStop = document.getElementById("white-stop-btn"); - 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 })); - }); - } + whiteStopBtn.addEventListener("click", async () => { + if (LIVE_STATES.has(currentState)) await teardown(); + }); - // 4. Live sync of #main-circle className → #white-mic-circle className - // (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. + // Retained quick actions are only available to an active realtime session. document.querySelectorAll(".quick-action-btn").forEach(btn => { btn.addEventListener("click", () => { const action = btn.getAttribute("data-action") || ""; @@ -2907,16 +2792,7 @@ function _applyThemeRuntime() { const text = prompts[action] || ""; if (!text) return; if (client && typeof client.sendUserText === "function") { - // Live session: send directly as if the user typed + pressed enter. 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) => { + if (client !== c) return; const detail = /** @type {CustomEvent<{ status: string }>} */ (e).detail; _kickIdleTimer(`client-status:${detail.status}`); onClientStatus(detail.status); @@ -3340,15 +3217,19 @@ async function doStart(audioContext = null) { }); }); c.addEventListener("error", (e) => { + if (client !== c) return; const detail = /** @type {CustomEvent<{ error: unknown }>} */ (e).detail; onFatalError(detail.error); }); c.addEventListener("server-error", (e) => { // 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 msg = detail.error instanceof Error ? detail.error.message : String(detail.error); 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) => { 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"); break; 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; case "error": setState("error"); @@ -3520,7 +3403,21 @@ function onClientStatus(status) { } } -async function teardown() { +/** @param {AppState} [finalState] @returns {Promise} */ +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(); stopJoinCountdown(); endTrackedSession(); @@ -3531,13 +3428,12 @@ async function teardown() { // 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. pendingWakeWordConfirmationText = ""; - if (client) { + if (closingClient) { try { - await client.close(); + await closingClient.close(); } catch (err) { console.warn("[main] error closing client:", err); } - client = null; } if (micStream) { for (const track of micStream.getTracks()) track.stop(); @@ -3545,7 +3441,7 @@ async function teardown() { } micMuted = false; micBtn.classList.remove("muted"); - setState("idle"); + setState(finalState); // Refresh the chip's remaining-today after the budget moved. if (limiterOn) void account.refresh(); } @@ -3553,13 +3449,20 @@ async function teardown() { /** @param {unknown} err */ function onFatalError(err) { console.error("[main] fatal:", err); + const wasLive = LIVE_STATES.has(currentState); setState("error"); const message = err instanceof Error ? err.message : String(err); - setCaption(truncateError(message), "error"); - void teardown().catch(() => { + const caption = message.includes("micrófono") + ? `${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"); - setCaption(truncateError(message), "error"); - }); + setCaption(caption, "error"); + }; + void teardown("error").then(showError, showError); } setState("idle"); diff --git a/s2s-client/style.css b/s2s-client/style.css index 22914f4..a55e432 100644 --- a/s2s-client/style.css +++ b/s2s-client/style.css @@ -505,7 +505,7 @@ a:hover { text-transform: uppercase; text-shadow: 0 1px 2px rgba(0,0,0,0.35); user-select: none; - cursor: default; + cursor: pointer; transition: transform .22s cubic-bezier(.2,.7,.2,1), box-shadow .3s ease, @@ -541,6 +541,11 @@ a:hover { 0 6px 22px rgba(0,0,0,0.28), 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 { 0%, 100% { opacity: 1; transform: scale(1); } 50% { opacity: .78; transform: scale(.9); } @@ -2459,88 +2464,6 @@ body.cam-on .footer { 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) ───────────────────────────────────────── */ /* Behaviour: - 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-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 { padding: 18px 18px 6px 18px; align-items: center; 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 ────────────────────────── - 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 avatar circle and the 2-line brand title/subtitle that plagued the previous implementation. Every item lives inside its own grid cell → no collision. */ @@ -4437,13 +4345,6 @@ html[data-theme="white"] .topbar { background: transparent; 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 */ html[data-theme="white"] .brand { grid-column: 2 / 3; @@ -4502,7 +4403,7 @@ html[data-theme="white"] .ident-user-name { width: 42px; height: 42px; border-radius: 999px; - background: linear-gradient(135deg, #3b82f6 0%, #60a5fa 100%); + background: linear-gradient(135deg, #1d4ed8 0%, #2563eb 100%); color: #ffffff; font-size: 13px; font-weight: 700; @@ -4511,6 +4412,10 @@ html[data-theme="white"] .ident-user-name { justify-content: center; border: 2px solid rgba(59, 130, 246, 0.25); 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 { 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 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 .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; } 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; 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.state-ai-speaking .wmc-icon.wmc-stop { - display: inline-flex !important; - width: 34px !important; - height: 34px !important; - margin: 7px !important; - border-radius: 4px !important; +html[data-theme="white"] .white-mic-circle:disabled { + cursor: default; +} +html[data-theme="white"] .white-mic-circle:focus-visible, +html[data-theme="white"] .white-stop-btn:focus-visible { + outline: 3px solid #1d4ed8; + outline-offset: 4px; } /* Subtle ring pulse while listening — uses box-shadow because the ring 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.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 ─────────────────────────────────────────────────── */ html[data-theme="white"] .white-stop-btn { @@ -4911,32 +4827,38 @@ html[data-theme="white"] .chat-panel-header { border-bottom-width: 1px !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 { display: inline-flex; align-items: center; gap: 7px; padding: 5px 11px 5px 8px; - background: rgba(22, 163, 74, 0.08); + background: #f1f5f9; border-radius: 999px; - color: #16a34a; + color: #475569; font-size: 12.5px; font-weight: 600; 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 { width: 9px; height: 9px; border-radius: 999px; - background: #16a34a; - box-shadow: 0 0 0 3px rgba(22, 163, 74, 0.14); + background: currentColor; display: block; 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. @@ -5297,95 +5219,6 @@ html[data-theme="white"] .chat-history .hist-msg.tool .hist-body::before { 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 ─── */ html[data-theme="white"] .app-footer { 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-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-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 { height: 56px; } html[data-theme="white"] .white-waveform-left { left: calc(50% - 49px - 76px); top: calc(14px + 49px); }